magus-cli 7.8.0 → 7.9.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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
  11. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  12. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  13. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  14. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  15. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  16. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  17. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  18. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  19. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  20. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  21. package/src/__tests__/blind-780-review2.test.ts +0 -25
  22. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  23. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  24. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  25. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  26. package/src/__tests__/deps-adapter.test.ts +191 -0
  27. package/src/__tests__/deps-check-hook.test.ts +220 -0
  28. package/src/__tests__/deps-plan.test.ts +897 -0
  29. package/src/__tests__/deps-report.test.ts +197 -0
  30. package/src/__tests__/doctor-bins.test.ts +134 -36
  31. package/src/__tests__/doctor-json.test.ts +261 -0
  32. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  33. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  34. package/src/__tests__/fixtures/bins.ts +55 -0
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  38. package/src/__tests__/npm-package-name.test.ts +6 -9
  39. package/src/__tests__/plugin-requires.test.ts +138 -153
  40. package/src/__tests__/requires-probe.test.ts +858 -0
  41. package/src/__tests__/resolver.test.ts +129 -52
  42. package/src/__tests__/run.test.ts +108 -0
  43. package/src/__tests__/tool-probe.test.ts +1 -1
  44. package/src/__tests__/toolchain.test.ts +123 -52
  45. package/src/__tests__/update-plan.test.ts +1 -1
  46. package/src/cli/ansi.ts +2 -7
  47. package/src/cli/deps-view.ts +213 -0
  48. package/src/cli/doctor.ts +169 -24
  49. package/src/cli/install.ts +51 -66
  50. package/src/cli/profile.ts +20 -2
  51. package/src/cli/router.ts +6 -2
  52. package/src/cli/update.ts +28 -7
  53. package/src/data/cli-tools.ts +24 -9
  54. package/src/services/active-closure.ts +3 -3
  55. package/src/services/claude-settings.ts +4 -348
  56. package/src/services/cli-tool-commands.ts +104 -3
  57. package/src/services/deps-plan.ts +1154 -0
  58. package/src/services/deps-report.ts +265 -0
  59. package/src/services/doctor-bins.ts +115 -43
  60. package/src/services/install-plan.ts +11 -1
  61. package/src/services/local-marketplace.ts +12 -206
  62. package/src/services/marketplace-refresh.ts +4 -4
  63. package/src/services/plugin-manager.ts +4 -18
  64. package/src/services/plugin-requires.ts +93 -162
  65. package/src/services/resolver.ts +168 -58
  66. package/src/services/tool-probe.ts +18 -25
  67. package/src/services/toolchain.ts +117 -111
  68. package/src/services/update-engine.ts +19 -14
  69. package/src/services/update-plan.ts +3 -3
  70. package/src/shared/plugin/hooks/bun-missing.sh +64 -0
  71. package/src/shared/plugin/hooks/deps-check.ts +240 -0
  72. package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
  73. package/src/types/index.ts +53 -16
  74. package/src/ui/App.tsx +6 -40
  75. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  76. package/src/ui/adapters/depsAdapter.ts +129 -0
  77. package/src/ui/adapters/startupReports.ts +0 -22
  78. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  79. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  80. package/src/ui/screens/PluginsScreen.tsx +12 -48
  81. package/src/utils/command-utils.ts +4 -5
  82. package/src/utils/run.ts +143 -52
  83. package/src/utils/string-utils.ts +9 -0
  84. package/src/__tests__/go-module.test.ts +0 -43
  85. package/src/__tests__/plugin-setup.test.ts +0 -1016
  86. package/src/__tests__/shell-script-callers.test.ts +0 -92
  87. package/src/services/plugin-setup.ts +0 -965
  88. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,858 @@
1
+ /**
2
+ * requires-probe — the one reader of plugin.json `requires`.
3
+ *
4
+ * Host inputs are pinned: every probe runs against a PATH and HOME built in a
5
+ * temp dir from fake executables, and the platform is passed in, so nothing
6
+ * here reports what this machine happens to have installed.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ chmodSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ rmSync,
15
+ symlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import {
21
+ type InstallOption,
22
+ type ProbeEnv,
23
+ REQUIRES_SCHEMA,
24
+ type Requirement,
25
+ USER_PREFIXES,
26
+ dedupeKey,
27
+ describeStep,
28
+ doctorFixCommand,
29
+ parseRequires,
30
+ probe,
31
+ rcFile,
32
+ releaseAsset,
33
+ releaseFormat,
34
+ renderFix,
35
+ renderInstall,
36
+ selectOption,
37
+ whyUnselectable,
38
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
39
+
40
+ const BUN: Requirement = {
41
+ name: "bun",
42
+ check: ["bun", "--version"],
43
+ install: [{ via: "bootstrap", tool: "bun" }],
44
+ };
45
+ const UV: Requirement = {
46
+ name: "uv",
47
+ check: ["uv", "--version"],
48
+ install: [{ via: "bootstrap", tool: "uv" }],
49
+ };
50
+ const TMUX: Requirement = {
51
+ name: "tmux",
52
+ check: ["tmux", "-V"],
53
+ install: [
54
+ { via: "brew", os: ["darwin"], formula: "tmux" },
55
+ { via: "apt", os: ["linux"], packages: ["tmux"] },
56
+ ],
57
+ };
58
+ const DIGEST = "a".repeat(64);
59
+ const TMUX_MCP: Requirement = {
60
+ name: "tmux-mcp",
61
+ check: ["tmux-mcp", "--version"],
62
+ install: [
63
+ {
64
+ via: "release",
65
+ repo: "MadAppGang/tmux-mcp",
66
+ tag: "v2.0.0",
67
+ asset: "tmux-mcp_{os}_{arch}.tar.gz",
68
+ binary: "tmux-mcp",
69
+ sha256: {
70
+ darwin_amd64: DIGEST,
71
+ darwin_arm64: DIGEST,
72
+ linux_amd64: DIGEST,
73
+ linux_arm64: DIGEST,
74
+ },
75
+ },
76
+ ],
77
+ };
78
+
79
+ const manifest = (bin: unknown[], extra: Record<string, unknown> = {}) => ({
80
+ name: "p",
81
+ requires: { schema: REQUIRES_SCHEMA, bin, ...extra },
82
+ });
83
+
84
+ function errorsOf(json: unknown): string[] {
85
+ const r = parseRequires(json);
86
+ return r.ok ? [] : r.errors;
87
+ }
88
+
89
+ describe("parseRequires", () => {
90
+ test("a full declaration parses to the same requirements", () => {
91
+ const r = parseRequires(manifest([BUN, TMUX, TMUX_MCP]));
92
+ expect(r).toEqual({ ok: true, requirements: [BUN, TMUX, TMUX_MCP] });
93
+ });
94
+
95
+ test("no requires declares nothing", () => {
96
+ expect(parseRequires({ name: "p" })).toEqual({
97
+ ok: true,
98
+ requirements: [],
99
+ });
100
+ });
101
+
102
+ test("reads only the requires key: other manifest keys are not its concern", () => {
103
+ expect(parseRequires({ name: "p", anything: 1 }).ok).toBe(true);
104
+ });
105
+
106
+ test("an unknown key anywhere inside requires is an error naming it", () => {
107
+ expect(errorsOf(manifest([BUN], { extra: 1 }))).toEqual([
108
+ 'requires: unknown key "extra"',
109
+ ]);
110
+ expect(errorsOf(manifest([{ ...BUN, via: "bun" }]))[0]).toContain(
111
+ 'unknown key "via"',
112
+ );
113
+ expect(
114
+ errorsOf(
115
+ manifest([
116
+ { ...BUN, install: [{ via: "bootstrap", tool: "bun", url: "x" }] },
117
+ ]),
118
+ )[0],
119
+ ).toContain('unknown key "url"');
120
+ });
121
+
122
+ test("an unknown via is an error, never a silent null", () => {
123
+ expect(
124
+ errorsOf(
125
+ manifest([{ name: "x", check: ["x"], install: [{ via: "cargo" }] }]),
126
+ ),
127
+ ).toEqual(['requires.bin[0] (x).install[0]: unknown via "cargo"']);
128
+ });
129
+
130
+ test("npm, pip and go are refused inside a manifest", () => {
131
+ for (const via of ["npm", "pip", "go"]) {
132
+ expect(
133
+ errorsOf(
134
+ manifest([
135
+ { name: "x", check: ["x"], install: [{ via, package: "x" }] },
136
+ ]),
137
+ ),
138
+ ).toEqual([
139
+ `requires.bin[0] (x).install[0]: via ${via} is not allowed in plugin.json`,
140
+ ]);
141
+ }
142
+ });
143
+
144
+ test("a missing check is an error: there is no default", () => {
145
+ expect(
146
+ errorsOf(
147
+ manifest([{ name: "x", install: [{ via: "brew", formula: "x" }] }]),
148
+ ),
149
+ ).toContain("requires.bin[0] (x).check: missing check (a non-empty argv)");
150
+ });
151
+
152
+ test("only ${CLAUDE_PLUGIN_ROOT} is expanded in a check", () => {
153
+ expect(
154
+ errorsOf(manifest([{ ...TMUX, check: ["${HOME}/bin/tmux", "-V"] }]))[0],
155
+ ).toContain("only ${CLAUDE_PLUGIN_ROOT} is expanded");
156
+ });
157
+
158
+ test("schema: newer asks for a newer magus-cli, older for a newer plugin", () => {
159
+ const newer = parseRequires({ requires: { schema: 2, bin: [] } });
160
+ expect(newer.ok ? null : newer.kind).toBe("newer-schema");
161
+ expect(newer.ok ? "" : newer.errors[0]).toContain("update magus-cli");
162
+ const older = parseRequires({ requires: { schema: 0, bin: [] } });
163
+ expect(older.ok ? null : older.kind).toBe("older-schema");
164
+ expect(older.ok ? "" : older.errors[0]).toContain("update the plugin");
165
+ const none = parseRequires({ requires: { bin: [] } });
166
+ expect(none.ok ? null : none.kind).toBe("invalid");
167
+ });
168
+
169
+ test("a bootstrap names a magus table entry equal to its requirement", () => {
170
+ expect(
171
+ errorsOf(
172
+ manifest([
173
+ {
174
+ name: "bun",
175
+ check: ["bun"],
176
+ install: [{ via: "bootstrap", tool: "rustup" }],
177
+ },
178
+ ]),
179
+ )[0],
180
+ ).toContain(".tool: must be one of bun, uv");
181
+ expect(
182
+ errorsOf(
183
+ manifest([
184
+ {
185
+ name: "node",
186
+ check: ["node"],
187
+ install: [{ via: "bootstrap", tool: "bun" }],
188
+ },
189
+ ]),
190
+ )[0],
191
+ ).toContain('bootstraps "bun" for a requirement named "node"');
192
+ });
193
+
194
+ test("bun, uv-script and uv-module options need their toolchain declared beside them", () => {
195
+ expect(
196
+ errorsOf(
197
+ manifest([
198
+ { name: "c", check: ["c"], install: [{ via: "bun", package: "c" }] },
199
+ ]),
200
+ ),
201
+ ).toEqual([
202
+ 'requires.bin (c): via bun needs "bun" declared in the same plugin',
203
+ ]);
204
+ expect(
205
+ errorsOf(
206
+ manifest([
207
+ {
208
+ name: "e",
209
+ check: ["e"],
210
+ install: [{ via: "uv-script", script: "s.py", args: [] }],
211
+ },
212
+ ]),
213
+ ),
214
+ ).toEqual([
215
+ 'requires.bin (e): via uv-script needs "uv" declared in the same plugin',
216
+ ]);
217
+ });
218
+
219
+ test("nothing in a manifest can become a shell word or leave the plugin root", () => {
220
+ const bad: [InstallOption | Record<string, unknown>, string][] = [
221
+ [{ via: "apt", packages: ["-o", "tmux"] }, ".packages"],
222
+ [{ via: "brew", formula: "tmux; rm -rf ~" }, ".formula"],
223
+ [{ via: "uv-script", script: "../evil.py", args: [] }, ".script"],
224
+ [{ via: "uv-script", script: "/abs.py", args: [] }, ".script"],
225
+ [
226
+ { via: "uv-module", script: "s.py", module: "os;x", args: [] },
227
+ ".module",
228
+ ],
229
+ [
230
+ {
231
+ via: "uv-module",
232
+ script: "s.py",
233
+ module: "m",
234
+ args: [],
235
+ root: "yes",
236
+ },
237
+ ".root",
238
+ ],
239
+ [
240
+ { ...(TMUX_MCP.install[0] as object), asset: "x_{os}_{secret}.tgz" },
241
+ ".asset",
242
+ ],
243
+ [{ ...(TMUX_MCP.install[0] as object), repo: "a/b/c" }, ".repo"],
244
+ [
245
+ {
246
+ ...(TMUX_MCP.install[0] as object),
247
+ sha256: { linux_amd64: "short" },
248
+ },
249
+ ".sha256.linux_amd64",
250
+ ],
251
+ ];
252
+ for (const [option, field] of bad) {
253
+ const errors = errorsOf(
254
+ manifest([BUN, UV, { name: "x", check: ["x"], install: [option] }]),
255
+ );
256
+ expect(errors.join("\n")).toContain(field);
257
+ }
258
+ });
259
+
260
+ test("the same name twice in one plugin is an error", () => {
261
+ expect(errorsOf(manifest([BUN, BUN]))).toEqual([
262
+ 'requires.bin: "bun" twice',
263
+ ]);
264
+ });
265
+ });
266
+
267
+ describe("dedupeKey", () => {
268
+ test("identical declarations share a key, whatever the key order", () => {
269
+ const reordered = JSON.parse(
270
+ '{"install":[{"tool":"bun","via":"bootstrap"}],"check":["bun","--version"],"name":"bun"}',
271
+ ) as Requirement;
272
+ expect(dedupeKey(reordered)).toBe(dedupeKey(BUN));
273
+ expect(dedupeKey(BUN)).toMatch(/^bun-[0-9a-f]{12}$/);
274
+ });
275
+
276
+ test("different declarations of one name never hide each other", () => {
277
+ expect(dedupeKey({ ...BUN, check: ["bun", "-v"] })).not.toBe(
278
+ dedupeKey(BUN),
279
+ );
280
+ });
281
+ });
282
+
283
+ // ─── probe ──────────────────────────────────────────────────────────────────────
284
+
285
+ let root: string;
286
+ let bin: string;
287
+ let home: string;
288
+ beforeEach(() => {
289
+ root = mkdtempSync(join(tmpdir(), "requires-probe-"));
290
+ bin = join(root, "bin");
291
+ home = join(root, "home");
292
+ mkdirSync(bin);
293
+ mkdirSync(home);
294
+ });
295
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
296
+
297
+ function tool(dir: string, name: string, body: string): void {
298
+ mkdirSync(dir, { recursive: true });
299
+ const file = join(dir, name);
300
+ writeFileSync(file, `#!/bin/sh\n${body}\n`);
301
+ chmodSync(file, 0o755);
302
+ }
303
+
304
+ const envFor = (over: Partial<ProbeEnv> = {}): ProbeEnv => ({
305
+ pluginRoot: root,
306
+ os: "linux",
307
+ arch: "amd64",
308
+ home,
309
+ path: bin,
310
+ capMs: 2_000,
311
+ depth: "full",
312
+ userShell: "/bin/zsh",
313
+ ...over,
314
+ });
315
+
316
+ const req = (
317
+ name: string,
318
+ check: string[],
319
+ os?: ("darwin" | "linux")[],
320
+ ): Requirement => ({
321
+ name,
322
+ check,
323
+ ...(os ? { os } : {}),
324
+ install: [{ via: "brew", formula: name }],
325
+ });
326
+
327
+ describe("probe", () => {
328
+ test("exit 0 is present, non-zero and absent are missing", async () => {
329
+ tool(bin, "ok", "exit 0");
330
+ tool(bin, "broken", "exit 3");
331
+ const result = await probe(
332
+ [req("ok", ["ok"]), req("broken", ["broken"]), req("gone", ["gone"])],
333
+ envFor(),
334
+ );
335
+ expect(result).toEqual([
336
+ { name: "ok", status: "present" },
337
+ { name: "broken", status: "missing" },
338
+ { name: "gone", status: "missing" },
339
+ ]);
340
+ });
341
+
342
+ test("a check over its cap is unknown (slow), never missing", async () => {
343
+ tool(bin, "slow", "exec /bin/sleep 5");
344
+ const [result] = await probe(
345
+ [req("slow", ["slow"])],
346
+ envFor({ capMs: 200 }),
347
+ );
348
+ expect(result?.status).toBe("unknown");
349
+ expect(result?.status === "unknown" ? result.reason : "").toContain("slow");
350
+ });
351
+
352
+ test("found only under a user prefix is off-path, with the prefix", async () => {
353
+ tool(join(home, ".bun", "bin"), "claudish", "exit 0");
354
+ tool(join(home, ".local", "bin"), "tmux-mcp", "exit 0");
355
+ const result = await probe(
356
+ [
357
+ req("claudish", ["claudish", "--version"]),
358
+ req("tmux-mcp", ["tmux-mcp"]),
359
+ ],
360
+ envFor(),
361
+ );
362
+ expect(result).toEqual([
363
+ { name: "claudish", status: "off-path", prefix: "~/.bun/bin" },
364
+ { name: "tmux-mcp", status: "off-path", prefix: "~/.local/bin" },
365
+ ]);
366
+ });
367
+
368
+ test("a check run through another missing declared dep is blocked by it", async () => {
369
+ const env = req("browser-use-env", ["uv", "run", "--script", "x.py"]);
370
+ const result = await probe([UV, env], envFor());
371
+ expect(result).toEqual([
372
+ { name: "uv", status: "missing" },
373
+ { name: "browser-use-env", status: "missing", blockedBy: "uv" },
374
+ ]);
375
+ });
376
+
377
+ test("the blocked check runs once its dep is present", async () => {
378
+ tool(
379
+ bin,
380
+ "uv",
381
+ 'test "$1" = --version && exit 0; test "$1" = run && exit 0; exit 1',
382
+ );
383
+ const env = req("browser-use-env", ["uv", "run", "--script", "x.py"]);
384
+ expect(await probe([UV, env], envFor())).toEqual([
385
+ { name: "uv", status: "present" },
386
+ { name: "browser-use-env", status: "present" },
387
+ ]);
388
+ });
389
+
390
+ test("${CLAUDE_PLUGIN_ROOT} expands to the plugin root; nothing else does", async () => {
391
+ tool(root, "marker.sh", "exit 0");
392
+ const result = await probe(
393
+ [req("rooted", ["${CLAUDE_PLUGIN_ROOT}/marker.sh"])],
394
+ envFor(),
395
+ );
396
+ expect(result).toEqual([{ name: "rooted", status: "present" }]);
397
+ });
398
+
399
+ test("presence depth decides a self-named check by PATH lookup, without running it", async () => {
400
+ // Would fail if run, and would take 5 s: presence depth must do neither.
401
+ tool(bin, "mnemex", "exec /bin/sleep 5");
402
+ const started = Date.now();
403
+ const result = await probe(
404
+ [req("mnemex", ["mnemex", "--version"])],
405
+ envFor({ depth: "presence" }),
406
+ );
407
+ expect(result).toEqual([{ name: "mnemex", status: "present" }]);
408
+ expect(Date.now() - started).toBeLessThan(1_000);
409
+ });
410
+
411
+ test("presence depth reads a dangling symlink as missing, and a user prefix as off-path", async () => {
412
+ symlinkSync(join(root, "nowhere"), join(bin, "tmux-mcp"));
413
+ tool(join(home, ".bun", "bin"), "claudish", "exit 3");
414
+ const result = await probe(
415
+ [
416
+ req("tmux-mcp", ["tmux-mcp", "--version"]),
417
+ req("claudish", ["claudish"]),
418
+ ],
419
+ envFor({ depth: "presence" }),
420
+ );
421
+ expect(result).toEqual([
422
+ { name: "tmux-mcp", status: "missing" },
423
+ { name: "claudish", status: "off-path", prefix: "~/.bun/bin" },
424
+ ]);
425
+ });
426
+
427
+ test("presence depth still runs a check that is not the requirement's own binary", async () => {
428
+ tool(bin, "uv", 'test "$1" = --version && exit 0; exit 4');
429
+ const env = req("browser-use-env", ["uv", "run", "--script", "x.py"]);
430
+ expect(await probe([UV, env], envFor({ depth: "presence" }))).toEqual([
431
+ { name: "uv", status: "present" },
432
+ { name: "browser-use-env", status: "missing" },
433
+ ]);
434
+ });
435
+
436
+ test("full depth runs a self-named check: present on PATH but failing is missing", async () => {
437
+ tool(bin, "mnemex", "exit 1");
438
+ expect(
439
+ await probe([req("mnemex", ["mnemex", "--version"])], envFor()),
440
+ ).toEqual([{ name: "mnemex", status: "missing" }]);
441
+ });
442
+
443
+ test("a requirement for another OS is not probed", async () => {
444
+ expect(
445
+ await probe([req("libs", ["libs"], ["linux"])], envFor({ os: "darwin" })),
446
+ ).toEqual([]);
447
+ });
448
+ });
449
+
450
+ // ─── selecting and rendering ────────────────────────────────────────────────────
451
+
452
+ const none = () => false;
453
+ const only =
454
+ (...tools: string[]) =>
455
+ (t: string) =>
456
+ tools.includes(t);
457
+
458
+ describe("selectOption", () => {
459
+ const UV_DECL: Requirement = {
460
+ name: "uv",
461
+ check: ["uv", "--version"],
462
+ install: [
463
+ { via: "brew", os: ["darwin"], formula: "uv" },
464
+ { via: "bootstrap", tool: "uv" },
465
+ ],
466
+ };
467
+
468
+ test("the first usable option in declaration order wins", () => {
469
+ const env = { os: "darwin" as const, arch: "arm64" as const };
470
+ expect(selectOption(UV_DECL, env, only("brew", "curl"))?.via).toBe("brew");
471
+ expect(selectOption(UV_DECL, env, only("curl"))?.via).toBe("bootstrap");
472
+ expect(
473
+ selectOption(UV_DECL, { ...env, os: "linux" }, only("curl", "brew"))?.via,
474
+ ).toBe("bootstrap");
475
+ });
476
+
477
+ test("nothing usable is null, and why says what to do", () => {
478
+ const env = { os: "darwin" as const, arch: "arm64" as const };
479
+ expect(selectOption(TMUX, env, none)).toBeNull();
480
+ expect(whyUnselectable(TMUX, env, none)).toBe(
481
+ "Homebrew is missing — install it from https://brew.sh first",
482
+ );
483
+ expect(whyUnselectable(UV_DECL, { ...env, os: "linux" }, none)).toBe(
484
+ "its installer needs curl: sudo apt-get install -y curl",
485
+ );
486
+ });
487
+
488
+ test("a release with no digest for this platform is unusable", () => {
489
+ const unpinned: Requirement = {
490
+ ...TMUX_MCP,
491
+ install: [
492
+ {
493
+ ...(TMUX_MCP.install[0] as InstallOption & { via: "release" }),
494
+ sha256: {},
495
+ },
496
+ ],
497
+ };
498
+ const env = { os: "linux" as const, arch: "arm64" as const };
499
+ expect(selectOption(unpinned, env, none)).toBeNull();
500
+ expect(whyUnselectable(unpinned, env, none)).toBe(
501
+ "no pinned build for linux_arm64",
502
+ );
503
+ });
504
+ });
505
+
506
+ describe("renderInstall + describeStep", () => {
507
+ const env = {
508
+ os: "linux" as const,
509
+ arch: "arm64" as const,
510
+ pluginRoot: "/p",
511
+ };
512
+
513
+ test("bun keeps the version pin in one argv word", () => {
514
+ const step = renderInstall(
515
+ { name: "c", check: ["c"], install: [] },
516
+ { via: "bun", package: "claudish", version: "1.2.3" },
517
+ env,
518
+ );
519
+ expect(step).toEqual({
520
+ kind: "argv",
521
+ name: "c",
522
+ escalation: "none",
523
+ argv: ["bun", "add", "-g", "claudish@1.2.3"],
524
+ });
525
+ });
526
+
527
+ test("apt is escalated, and only apt and root uv-module are", () => {
528
+ const apt = renderInstall(TMUX, TMUX.install[1] as InstallOption, env);
529
+ expect(describeStep(apt, "linux")).toBe(
530
+ "sudo apt-get install -y --no-install-recommends tmux",
531
+ );
532
+ const brew = renderInstall(TMUX, TMUX.install[0] as InstallOption, env);
533
+ expect(describeStep(brew, "darwin")).toBe("brew install tmux");
534
+ });
535
+
536
+ test("release: the fixed GitHub host, the platform's digest, verified before install", () => {
537
+ const step = renderInstall(
538
+ TMUX_MCP,
539
+ TMUX_MCP.install[0] as InstallOption,
540
+ env,
541
+ );
542
+ expect(step).toMatchObject({
543
+ kind: "release",
544
+ url: "https://github.com/MadAppGang/tmux-mcp/releases/download/v2.0.0/tmux-mcp_linux_arm64.tar.gz",
545
+ sha256: DIGEST,
546
+ dest: "~/.local/bin",
547
+ });
548
+ expect(describeStep(step, "linux")).toContain(
549
+ `echo '${DIGEST} tmux-mcp_linux_arm64.tar.gz' | sha256sum -c`,
550
+ );
551
+ expect(describeStep(step, "darwin")).toContain("shasum -a 256 -c");
552
+ });
553
+
554
+ test("uv-module runs the module with the script env's interpreter, sudo only when root", () => {
555
+ const libs = renderInstall(
556
+ { name: "chromium-libs", check: ["x"], install: [] },
557
+ {
558
+ via: "uv-module",
559
+ script: "scripts/mcp-server.py",
560
+ module: "playwright",
561
+ args: ["install-deps", "chromium"],
562
+ root: true,
563
+ },
564
+ env,
565
+ );
566
+ expect(libs).toMatchObject({
567
+ kind: "uv-module",
568
+ escalation: "sudo",
569
+ script: "/p/scripts/mcp-server.py",
570
+ });
571
+ expect(describeStep(libs, "linux")).toBe(
572
+ 'uv sync --no-config --script /p/scripts/mcp-server.py && sudo "$(uv python find --no-config --script /p/scripts/mcp-server.py)" -B -m playwright install-deps chromium',
573
+ );
574
+ });
575
+
576
+ test("uv-script builds the env with no config from the directory it runs in", () => {
577
+ const step = renderInstall(
578
+ { name: "browser-use-env", check: ["x"], install: [] },
579
+ {
580
+ via: "uv-script",
581
+ script: "scripts/mcp-server.py",
582
+ args: ["--check-env"],
583
+ },
584
+ env,
585
+ );
586
+ expect(step).toMatchObject({
587
+ kind: "argv",
588
+ argv: [
589
+ "uv",
590
+ "run",
591
+ "--no-config",
592
+ "--script",
593
+ "/p/scripts/mcp-server.py",
594
+ "--check-env",
595
+ ],
596
+ });
597
+ });
598
+
599
+ test("bootstrap renders the upstream installer from magus's table", () => {
600
+ const step = renderInstall(BUN, BUN.install[0] as InstallOption, env);
601
+ expect(describeStep(step, "linux")).toBe(
602
+ "curl -fsSL https://bun.sh/install | bash",
603
+ );
604
+ });
605
+
606
+ test("a brew cask installs with --cask; a formula without", () => {
607
+ const parsed = parseRequires({
608
+ requires: {
609
+ schema: REQUIRES_SCHEMA,
610
+ bin: [
611
+ {
612
+ name: "madbench",
613
+ check: ["madbench", "--version"],
614
+ install: [{ via: "brew", cask: "madappgang/tap/madbench" }],
615
+ },
616
+ ],
617
+ },
618
+ });
619
+ expect(parsed.ok).toBe(true);
620
+ if (!parsed.ok) return;
621
+ const r = parsed.requirements[0] as Requirement;
622
+ const step = renderInstall(r, r.install[0] as InstallOption, env);
623
+ expect(step).toMatchObject({
624
+ argv: ["brew", "install", "--cask", "madappgang/tap/madbench"],
625
+ });
626
+ expect(describeStep(step, "darwin")).toBe(
627
+ "brew install --cask madappgang/tap/madbench",
628
+ );
629
+ });
630
+
631
+ test("a brew option takes exactly one of formula and cask", () => {
632
+ for (const option of [
633
+ { via: "brew" },
634
+ { via: "brew", formula: "a", cask: "b" },
635
+ ]) {
636
+ const parsed = parseRequires({
637
+ requires: {
638
+ schema: REQUIRES_SCHEMA,
639
+ bin: [{ name: "x", check: ["x"], install: [option] }],
640
+ },
641
+ });
642
+ expect(parsed).toMatchObject({ ok: false, kind: "invalid" });
643
+ if (parsed.ok) continue;
644
+ expect(parsed.errors.join("\n")).toContain(
645
+ "brew takes exactly one of formula and cask",
646
+ );
647
+ }
648
+ });
649
+
650
+ test("a bare-binary release: {arch} in the release's own spelling, verified, installed as the binary — no tar", () => {
651
+ const claudish: InstallOption = {
652
+ via: "release",
653
+ repo: "MadAppGang/claudish",
654
+ tag: "v10.3.0",
655
+ asset: "claudish-{os}-{arch}",
656
+ binary: "claudish",
657
+ arch: { amd64: "x64" },
658
+ sha256: { linux_amd64: DIGEST, linux_arm64: DIGEST },
659
+ };
660
+ const req = { name: "claudish", check: ["claudish"], install: [claudish] };
661
+ const amd = renderInstall(req, claudish, { ...env, arch: "amd64" });
662
+ expect(amd).toMatchObject({
663
+ kind: "release",
664
+ format: "binary",
665
+ url: "https://github.com/MadAppGang/claudish/releases/download/v10.3.0/claudish-linux-x64",
666
+ });
667
+ const arm = renderInstall(req, claudish, env);
668
+ expect(arm).toMatchObject({
669
+ url: "https://github.com/MadAppGang/claudish/releases/download/v10.3.0/claudish-linux-arm64",
670
+ });
671
+ expect(describeStep(amd, "linux")).toBe(
672
+ `cd $(mktemp -d) && curl -fsSLO https://github.com/MadAppGang/claudish/releases/download/v10.3.0/claudish-linux-x64 && echo '${DIGEST} claudish-linux-x64' | sha256sum -c && mkdir -p ~/.local/bin && install -m 755 claudish-linux-x64 ~/.local/bin/claudish`,
673
+ );
674
+ expect(releaseFormat("tmux-mcp_linux_arm64.tar.gz")).toBe("tar.gz");
675
+ expect(releaseAsset(claudish, "darwin", "amd64")).toBe(
676
+ "claudish-darwin-x64",
677
+ );
678
+ });
679
+
680
+ test("a release asset is a .tar.gz archive or the binary itself, never another archive", () => {
681
+ for (const [asset, arch, error] of [
682
+ ["x_{os}_{arch}.zip", undefined, "an archive must be .tar.gz or .tgz"],
683
+ ["x_{os}_{arch}.tar.xz", undefined, "an archive must be .tar.gz or .tgz"],
684
+ ["x-{os}-{arch}", { i386: "x86" }, 'unknown arch "i386"'],
685
+ ] as const) {
686
+ const parsed = parseRequires({
687
+ requires: {
688
+ schema: REQUIRES_SCHEMA,
689
+ bin: [
690
+ {
691
+ name: "x",
692
+ check: ["x"],
693
+ install: [
694
+ {
695
+ via: "release",
696
+ repo: "a/b",
697
+ tag: "v1.0.0",
698
+ asset,
699
+ binary: "x",
700
+ ...(arch ? { arch } : {}),
701
+ sha256: {},
702
+ },
703
+ ],
704
+ },
705
+ ],
706
+ },
707
+ });
708
+ expect(parsed.ok).toBe(false);
709
+ if (parsed.ok) continue;
710
+ expect(parsed.errors.join("\n")).toContain(error);
711
+ }
712
+ });
713
+ });
714
+
715
+ describe("renderFix", () => {
716
+ const reqs = [BUN, TMUX, TMUX_MCP];
717
+ const linuxEnv = (): ProbeEnv => {
718
+ tool(bin, "apt-get", "exit 0");
719
+ return envFor({ os: "linux", arch: "amd64" });
720
+ };
721
+
722
+ test("healthy is silent", () => {
723
+ expect(
724
+ renderFix(
725
+ "terminal",
726
+ [{ name: "tmux", status: "present" }],
727
+ reqs,
728
+ envFor(),
729
+ true,
730
+ ),
731
+ ).toBeNull();
732
+ });
733
+
734
+ test("unknown alone is silent: a slow check is not a finding", () => {
735
+ expect(
736
+ renderFix(
737
+ "terminal",
738
+ [{ name: "tmux", status: "unknown", reason: "slow" }],
739
+ reqs,
740
+ envFor(),
741
+ true,
742
+ ),
743
+ ).toBeNull();
744
+ });
745
+
746
+ test("missing deps: marker, names, the fix, and each by-hand line", () => {
747
+ const message = renderFix(
748
+ "terminal",
749
+ [
750
+ { name: "tmux", status: "missing" },
751
+ { name: "tmux-mcp", status: "missing" },
752
+ { name: "bun", status: "unknown", reason: "slow" },
753
+ ],
754
+ reqs,
755
+ linuxEnv(),
756
+ false,
757
+ ) as string;
758
+ expect(message.split("\n")[0]).toBe(
759
+ "magus-deps · terminal: missing tmux, tmux-mcp; unknown (slow) bun.",
760
+ );
761
+ expect(message).toContain(
762
+ "Fix all: bun add -g magus-cli, then magus doctor --fix",
763
+ );
764
+ expect(message).toContain(
765
+ "tmux: sudo apt-get install -y --no-install-recommends tmux",
766
+ );
767
+ expect(message).toContain(
768
+ "releases/download/v2.0.0/tmux-mcp_linux_amd64.tar.gz",
769
+ );
770
+ expect(message).not.toContain("bun: ");
771
+ });
772
+
773
+ test("with magus on PATH the fix is magus alone", () => {
774
+ const message = renderFix(
775
+ "terminal",
776
+ [{ name: "tmux", status: "missing" }],
777
+ reqs,
778
+ linuxEnv(),
779
+ true,
780
+ ) as string;
781
+ expect(message).toContain("Fix all: magus doctor --fix");
782
+ expect(message).not.toContain("magus-cli");
783
+ });
784
+
785
+ test("the fix names the session's project, quoted for a shell", () => {
786
+ expect(doctorFixCommand(null)).toBe("magus doctor --fix");
787
+ expect(doctorFixCommand("/srv/app")).toBe(
788
+ "magus doctor --fix --project /srv/app",
789
+ );
790
+ expect(doctorFixCommand("/srv/it's here")).toBe(
791
+ "magus doctor --fix --project '/srv/it'\\''s here'",
792
+ );
793
+ const message = renderFix(
794
+ "terminal",
795
+ [{ name: "tmux", status: "missing" }],
796
+ reqs,
797
+ linuxEnv(),
798
+ false,
799
+ "/srv/app",
800
+ ) as string;
801
+ expect(message).toContain(
802
+ "Fix all: bun add -g magus-cli, then magus doctor --fix --project /srv/app\n",
803
+ );
804
+ });
805
+
806
+ test("the encoded banner has no quote before the lines a regex reads", () => {
807
+ const message = renderFix(
808
+ "terminal",
809
+ [
810
+ { name: "tmux", status: "missing" },
811
+ { name: "tmux-mcp", status: "missing" },
812
+ ],
813
+ reqs,
814
+ linuxEnv(),
815
+ false,
816
+ ) as string;
817
+ const encoded = JSON.stringify(message);
818
+ const url = encoded.indexOf("releases/download");
819
+ expect(url).toBeGreaterThan(0);
820
+ expect(encoded.slice(1, url)).not.toContain('\\"');
821
+ });
822
+
823
+ test("off-path names the prefix, the rc line and the rc file for the user's shell", () => {
824
+ const message = renderFix(
825
+ "claudish",
826
+ [{ name: "claudish", status: "off-path", prefix: "~/.bun/bin" }],
827
+ [
828
+ BUN,
829
+ {
830
+ name: "claudish",
831
+ check: ["claudish"],
832
+ install: [{ via: "bun", package: "claudish" }],
833
+ },
834
+ ],
835
+ envFor({ userShell: "/bin/zsh" }),
836
+ true,
837
+ ) as string;
838
+ expect(message).toContain("not on PATH claudish");
839
+ expect(message).toContain(
840
+ 'add export PATH="$HOME/.bun/bin:$PATH" to ~/.zshrc',
841
+ );
842
+ expect(message).toContain("restart Claude Code from a new terminal");
843
+ });
844
+ });
845
+
846
+ describe("USER_PREFIXES", () => {
847
+ test("fish gets fish_add_path; every other shell an export line", () => {
848
+ const local = USER_PREFIXES.find((p) => p.dir === "~/.local/bin");
849
+ expect(local?.rcLine("/usr/bin/fish")).toBe(
850
+ "fish_add_path $HOME/.local/bin",
851
+ );
852
+ expect(local?.rcLine("/bin/bash")).toBe(
853
+ 'export PATH="$HOME/.local/bin:$PATH"',
854
+ );
855
+ expect(rcFile("/bin/bash")).toBe("~/.bashrc");
856
+ expect(rcFile("/opt/homebrew/bin/fish")).toBe("~/.config/fish/config.fish");
857
+ });
858
+ });