magus-cli 7.8.0 → 7.9.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.
Files changed (78) 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-780-cli-tools.test.ts +36 -48
  11. package/src/__tests__/blind-780-review2.test.ts +0 -25
  12. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  13. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  14. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  15. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  16. package/src/__tests__/deps-adapter.test.ts +191 -0
  17. package/src/__tests__/deps-check-hook.test.ts +220 -0
  18. package/src/__tests__/deps-plan.test.ts +896 -0
  19. package/src/__tests__/deps-report.test.ts +197 -0
  20. package/src/__tests__/doctor-bins.test.ts +134 -36
  21. package/src/__tests__/doctor-json.test.ts +261 -0
  22. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  23. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  24. package/src/__tests__/fixtures/bins.ts +55 -0
  25. package/src/__tests__/install-plan.test.ts +15 -1
  26. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  27. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  28. package/src/__tests__/npm-package-name.test.ts +6 -9
  29. package/src/__tests__/plugin-requires.test.ts +138 -153
  30. package/src/__tests__/requires-probe.test.ts +858 -0
  31. package/src/__tests__/resolver.test.ts +129 -52
  32. package/src/__tests__/run.test.ts +108 -0
  33. package/src/__tests__/tool-probe.test.ts +1 -1
  34. package/src/__tests__/toolchain.test.ts +123 -52
  35. package/src/__tests__/update-plan.test.ts +1 -1
  36. package/src/cli/ansi.ts +2 -7
  37. package/src/cli/deps-view.ts +213 -0
  38. package/src/cli/doctor.ts +169 -24
  39. package/src/cli/install.ts +51 -66
  40. package/src/cli/profile.ts +20 -2
  41. package/src/cli/router.ts +6 -2
  42. package/src/cli/update.ts +28 -7
  43. package/src/data/cli-tools.ts +24 -9
  44. package/src/services/active-closure.ts +3 -3
  45. package/src/services/claude-settings.ts +4 -348
  46. package/src/services/cli-tool-commands.ts +104 -3
  47. package/src/services/deps-plan.ts +962 -0
  48. package/src/services/deps-report.ts +265 -0
  49. package/src/services/doctor-bins.ts +115 -43
  50. package/src/services/install-plan.ts +11 -1
  51. package/src/services/local-marketplace.ts +12 -206
  52. package/src/services/marketplace-refresh.ts +4 -4
  53. package/src/services/plugin-manager.ts +4 -18
  54. package/src/services/plugin-requires.ts +93 -162
  55. package/src/services/resolver.ts +168 -58
  56. package/src/services/tool-probe.ts +18 -25
  57. package/src/services/toolchain.ts +117 -111
  58. package/src/services/update-engine.ts +19 -14
  59. package/src/services/update-plan.ts +3 -3
  60. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  61. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  62. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  63. package/src/types/index.ts +53 -16
  64. package/src/ui/App.tsx +6 -40
  65. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  66. package/src/ui/adapters/depsAdapter.ts +129 -0
  67. package/src/ui/adapters/startupReports.ts +0 -22
  68. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  69. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  70. package/src/ui/screens/PluginsScreen.tsx +12 -48
  71. package/src/utils/command-utils.ts +4 -5
  72. package/src/utils/run.ts +123 -52
  73. package/src/utils/string-utils.ts +9 -0
  74. package/src/__tests__/go-module.test.ts +0 -43
  75. package/src/__tests__/plugin-setup.test.ts +0 -1016
  76. package/src/__tests__/shell-script-callers.test.ts +0 -92
  77. package/src/services/plugin-setup.ts +0 -965
  78. package/src/utils/go-module.ts +0 -36
@@ -59,6 +59,7 @@ import { renderPluginDetail } from "../ui/renderers/pluginRenderers.js";
59
59
  import { pluginRowText } from "../ui/renderers/pluginRowText.js";
60
60
  import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
61
61
  import type { FailureReport } from "../ui/state/types.js";
62
+ import type { RunResult } from "../utils/run.js";
62
63
  import { userFacingPath } from "../utils/user-path.js";
63
64
 
64
65
  // ── builders ────────────────────────────────────────────────────────────────
@@ -129,20 +130,28 @@ const BINARY_PATH = /(^|[\s'"(])\/[^\s'"]*\/bin\/[\w.-]+/;
129
130
  describe("B1/B2: installer failures keep their own fix and never print a binary path", () => {
130
131
  const ABS_NPM = { cmd: "/usr/local/bin/npm", args: ["install", "-g", "x"] };
131
132
 
132
- function execFailed(stderr: string, code: number | null = 1): Error {
133
- return Object.assign(
134
- new Error(`Command failed: ${ABS_NPM.cmd} install -g x`),
135
- { code, stderr, stdout: "", killed: false, signal: null },
136
- );
133
+ function execFailed(stderr: string, code: number | null = 1): RunResult {
134
+ return {
135
+ exitCode: code,
136
+ signal: null,
137
+ timedOut: false,
138
+ outputTail: stderr,
139
+ durationMs: 0,
140
+ };
137
141
  }
138
142
 
139
143
  test("the spec's two examples read without the path", () => {
140
144
  expect(
141
145
  commandFailureReason(
142
146
  ABS_NPM,
143
- Object.assign(new Error(`spawn ${ABS_NPM.cmd} ENOENT`), {
144
- code: "ENOENT",
145
- }),
147
+ {
148
+ exitCode: null,
149
+ signal: null,
150
+ timedOut: false,
151
+ spawnError: `spawn ${ABS_NPM.cmd} ENOENT`,
152
+ outputTail: "",
153
+ durationMs: 0,
154
+ },
146
155
  60_000,
147
156
  ),
148
157
  ).toBe("npm is not installed on this machine");
@@ -155,7 +164,14 @@ describe("B1/B2: installer failures keep their own fix and never print a binary
155
164
  const cmd = { cmd: "/Users/someone/.local/bin/claude", args: ["update"] };
156
165
  const reason = commandFailureReason(
157
166
  cmd,
158
- Object.assign(new Error(`spawn ${cmd.cmd} ENOENT`), { code: "ENOENT" }),
167
+ {
168
+ exitCode: null,
169
+ signal: null,
170
+ timedOut: false,
171
+ spawnError: `spawn ${cmd.cmd} ENOENT`,
172
+ outputTail: "",
173
+ durationMs: 0,
174
+ },
159
175
  60_000,
160
176
  );
161
177
  expect(reason).toBe("claude CLI not found in PATH");
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Test builders for requirements, from the version-managed shape tests read
3
+ * best (`{ name, via, package, version }`), through the one bridge production
4
+ * uses (`managedOption`). Shared so no test hand-builds the `SelectedBin`
5
+ * shape its own way.
6
+ */
7
+
8
+ import { type CliTool, cliTools, isOwnedEntry } from "../../data/cli-tools.js";
9
+ import { ownedCliTool } from "../../services/cli-tool-commands.js";
10
+ import { managedOption } from "../../services/toolchain.js";
11
+ import type {
12
+ ManagedTool,
13
+ ResolvedBin,
14
+ SelectedBin,
15
+ } from "../../types/index.js";
16
+
17
+ /** A selected requirement whose one option (and choice) is `t`'s installer. */
18
+ export function selected(t: ManagedTool, root?: string): SelectedBin {
19
+ const option = managedOption(t);
20
+ return {
21
+ name: t.name,
22
+ check: (t.check ?? `${t.name} --version`).trim().split(/\s+/),
23
+ install: [option],
24
+ chosen: option,
25
+ ...(root ? { root } : {}),
26
+ };
27
+ }
28
+
29
+ /** {@link selected}, with provenance. */
30
+ export function resolved(t: ManagedTool, sources: string[]): ResolvedBin {
31
+ return { ...selected(t), sources };
32
+ }
33
+
34
+ /**
35
+ * A plugin-owned catalogue tool's requirement as its plugin declares it today:
36
+ * a bun install of the package named after the tool.
37
+ */
38
+ export async function ownedAsDeclared(
39
+ _pluginId: string,
40
+ name: string,
41
+ ): Promise<SelectedBin> {
42
+ return selected({ name, via: "bun", package: name });
43
+ }
44
+
45
+ /** The catalogue as CLI tools, owned entries resolved as {@link ownedAsDeclared}. */
46
+ export function testCatalogue(): CliTool[] {
47
+ return cliTools.map((entry) =>
48
+ isOwnedEntry(entry)
49
+ ? (ownedCliTool(
50
+ entry,
51
+ selected({ name: entry.name, via: "bun", package: entry.name }),
52
+ ) as CliTool)
53
+ : entry,
54
+ );
55
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  InstalledPluginsRegistry,
9
9
  ResolvedClosure,
10
10
  } from "../types/index.js";
11
+ import { resolved } from "./fixtures/bins.js";
11
12
 
12
13
  function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
13
14
  return {
@@ -91,7 +92,7 @@ describe("summarizeClosure", () => {
91
92
  marketplaces: { magus: { source: "github", repo: "MadAppGang/magus" } },
92
93
  plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
93
94
  bins: [
94
- { name: "claudish", via: "bun", version: "1.2.0", sources: ["x"] },
95
+ resolved({ name: "claudish", via: "bun", version: "1.2.0" }, ["x"]),
95
96
  ],
96
97
  env: { required: ["FIGMA_TOKEN"], optional: [] },
97
98
  });
@@ -101,4 +102,17 @@ describe("summarizeClosure", () => {
101
102
  expect(lines.some((l) => l.includes("claudish@1.2.0"))).toBe(true);
102
103
  expect(lines.some((l) => l.startsWith("env"))).toBe(true);
103
104
  });
105
+
106
+ test("a refused pin is shown with the tool, marked as not applied", () => {
107
+ const bin = {
108
+ ...resolved({ name: "claudish", via: "brew", formula: "claudish" }, [
109
+ "profile:p.cliTools",
110
+ ]),
111
+ refusedPins: ["6.1.0"],
112
+ };
113
+ const lines = summarizeClosure(closure({ bins: [bin] }));
114
+ expect(
115
+ lines.some((l) => l.includes("claudish@6.1.0 (pin not applied)")),
116
+ ).toBe(true);
117
+ });
104
118
  });
@@ -57,7 +57,13 @@ function writePluginJson(dir: string, version: string, pin: string) {
57
57
  fs.mkdirSync(p, { recursive: true });
58
58
  fs.writeFileSync(
59
59
  path.join(p, "plugin.json"),
60
- JSON.stringify({ version, setup: { go: [pin] } }),
60
+ JSON.stringify({
61
+ version,
62
+ requires: {
63
+ schema: 1,
64
+ bin: [{ name: "x", install: [{ via: "release", tag: pin }] }],
65
+ },
66
+ }),
61
67
  );
62
68
  }
63
69
  function readPin(dir: string): { version: string; pin: string } {
@@ -67,7 +73,7 @@ function readPin(dir: string): { version: string; pin: string } {
67
73
  "utf8",
68
74
  ),
69
75
  );
70
- return { version: j.version, pin: j.setup.go[0] };
76
+ return { version: j.version, pin: j.requires.bin[0].install[0].tag };
71
77
  }
72
78
  /** Clone origin into the marketplace dir for `name`, at origin's current HEAD. */
73
79
  function installClone(name: string): string {
@@ -111,7 +117,7 @@ afterEach(() => {
111
117
  });
112
118
 
113
119
  describe("refreshRegisteredMarketplaces — the fix (CLAIM #1)", () => {
114
- it("fast-forwards a stale clone so a newly pinned setup.go becomes visible", async () => {
120
+ it("fast-forwards a stale clone so a newly pinned release tag becomes visible", async () => {
115
121
  const dir = installClone("magus");
116
122
  expect(readPin(dir).pin).toBe("x@latest"); // stale on-disk clone
117
123
  publishV2(); // a release lands upstream
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The plugin-dependency code paths never hand anything to a shell.
3
+ *
4
+ * Scoped, deliberately, to the files on the installer path: the one reader of
5
+ * `requires` (and the hook that runs it at session start), the executor, the
6
+ * runner, and the modules that feed them. Package names, check argv and script
7
+ * paths reach these from plugin manifests and profiles — data a marketplace or
8
+ * a teammate wrote — so here a shell is never acceptable. The test makes no
9
+ * claim about the rest of `src/`. A new file on the installer path joins
10
+ * {@link INSTALLER_PATH}.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import path from "node:path";
15
+ import fs from "fs-extra";
16
+
17
+ const SRC_DIR = path.join(import.meta.dir, "..");
18
+
19
+ /** Files on the installer path, relative to `src/`. */
20
+ const INSTALLER_PATH = [
21
+ "shared/plugin/hooks/lib/requires-probe.ts",
22
+ "shared/plugin/hooks/deps-check.ts",
23
+ "services/deps-plan.ts",
24
+ "services/deps-report.ts",
25
+ "services/doctor-bins.ts",
26
+ "utils/run.ts",
27
+ "cli/deps-view.ts",
28
+ "ui/adapters/depsAdapter.ts",
29
+ ];
30
+
31
+ /** Each way a string can reach a shell, as the line that would do it. */
32
+ const SHELL_PATTERNS: Array<[string, RegExp]> = [
33
+ ["a shell spawn option", /\bshell:\s*(true|["'`])/],
34
+ ["exec / execSync (string commands)", /\bexec(Sync)?\s*\(/],
35
+ [
36
+ "a string handed to spawn",
37
+ /(?<!Bun\.)\bspawn(Sync)?\s*\(\s*["'`][^"'`]*\s/,
38
+ ],
39
+ ["a string handed to Bun.spawn", /\bBun\.spawn(Sync)?\s*\(\s*["'`]/],
40
+ ["Bun's shell", /\$`|from\s+["']bun["'].*\$/],
41
+ ];
42
+
43
+ const isComment = (line: string) => {
44
+ const t = line.trim();
45
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
46
+ };
47
+
48
+ function shellUses(text: string): string[] {
49
+ const found: string[] = [];
50
+ text.split("\n").forEach((line, i) => {
51
+ if (isComment(line)) return;
52
+ for (const [what, re] of SHELL_PATTERNS) {
53
+ if (re.test(line)) found.push(`line ${i + 1}: ${what}`);
54
+ }
55
+ });
56
+ return found;
57
+ }
58
+
59
+ describe("the installer path never spawns a shell", () => {
60
+ for (const rel of INSTALLER_PATH) {
61
+ test(rel, async () => {
62
+ const text = await fs.readFile(path.join(SRC_DIR, rel), "utf8");
63
+ expect(shellUses(text)).toEqual([]);
64
+ });
65
+ }
66
+
67
+ test("the scan can fail", () => {
68
+ // Negative controls: each pattern fires on the line it exists for.
69
+ expect(shellUses("spawn(cmd, { shell: true });")).toHaveLength(1);
70
+ expect(shellUses(`spawn(cmd, { shell: "/bin/sh" });`)).toHaveLength(1);
71
+ expect(shellUses("execSync(`brew install ${f}`);")).toHaveLength(1);
72
+ expect(shellUses(`spawn("brew install tmux");`)).toHaveLength(1);
73
+ expect(shellUses(`Bun.spawn("brew install tmux");`)).toHaveLength(1);
74
+ // …and not on argv.
75
+ expect(shellUses(`spawn(exe, args, { stdio: "ignore" });`)).toEqual([]);
76
+ });
77
+ });
@@ -199,7 +199,7 @@ describe("the CLI Tools entry for magus", () => {
199
199
  test("install targets the package", () => {
200
200
  expect(cliToolInstall(magusEntry())).toEqual({
201
201
  cmd: "bun",
202
- args: ["install", "-g", PACKAGE],
202
+ args: ["add", "-g", PACKAGE],
203
203
  });
204
204
  });
205
205
 
@@ -251,10 +251,8 @@ describe("pins and probes resolve to the package", () => {
251
251
  test("a pinned version installs `<package>@<version>` and checks the command", () => {
252
252
  expect(cliToolToBin(COMMAND, PKG.version)).toMatchObject({
253
253
  name: COMMAND,
254
- via: "bun",
255
- package: PACKAGE,
256
- check: "magus --version",
257
- version: PKG.version,
254
+ check: ["magus", "--version"],
255
+ chosen: { via: "bun", package: PACKAGE, version: PKG.version },
258
256
  });
259
257
  });
260
258
 
@@ -262,11 +260,10 @@ describe("pins and probes resolve to the package", () => {
262
260
  const bin = cliToolToBin(COMMAND, "latest");
263
261
  expect(bin).toMatchObject({
264
262
  name: COMMAND,
265
- via: "bun",
266
- package: PACKAGE,
267
- check: "magus --version",
263
+ check: ["magus", "--version"],
264
+ chosen: { via: "bun", package: PACKAGE },
268
265
  });
269
- expect(bin.version).toBeUndefined();
266
+ expect(bin.chosen && "version" in bin.chosen).toBe(false);
270
267
  });
271
268
 
272
269
  test("the catalogue spec (the dist-tags probe input) names the package", () => {
@@ -1,187 +1,172 @@
1
+ /**
2
+ * plugin-requires locates the plugin.json Claude Code loads and hands its
3
+ * `requires` to requires-probe; it parses nothing itself.
4
+ */
5
+
1
6
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2
8
  import { mkdtemp, rm } from "node:fs/promises";
3
9
  import { tmpdir } from "node:os";
4
10
  import { join } from "node:path";
5
11
  import fs from "fs-extra";
6
12
  import {
7
- parsePluginRequires,
8
13
  readPluginRequires,
14
+ resolvePluginRequires,
15
+ selectBins,
9
16
  } from "../services/plugin-requires.js";
17
+ import type { Requirement } from "../shared/plugin/hooks/lib/requires-probe.js";
10
18
 
11
- describe("parsePluginRequires", () => {
12
- test("parses a valid requires.bin and fills defaults", () => {
13
- const requires = parsePluginRequires({
14
- name: "terminal",
15
- requires: {
16
- bin: [
17
- { name: "tmux", via: "brew", formula: "tmux", check: "tmux -V" },
18
- { name: "tmux-mcp", via: "bun", package: "tmux-mcp" },
19
- ],
20
- },
21
- });
22
- expect(requires.bin).toHaveLength(2);
23
- const tmux = requires.bin!.find((b) => b.name === "tmux")!;
24
- expect(tmux.via).toBe("brew");
25
- expect(tmux.check).toBe("tmux -V");
26
- const mcp = requires.bin!.find((b) => b.name === "tmux-mcp")!;
27
- // check defaulted from the name
28
- expect(mcp.check).toBe("tmux-mcp --version");
29
- });
30
-
31
- test("drops entries with no name or an unknown installer", () => {
32
- const requires = parsePluginRequires({
33
- requires: {
34
- bin: [
35
- { via: "bun" }, // no name
36
- { name: "x", via: "cargo" }, // unknown installer
37
- { name: "ok", via: "npm" },
38
- ],
39
- },
40
- });
41
- expect(requires.bin).toHaveLength(1);
42
- expect(requires.bin![0]!.name).toBe("ok");
43
- });
19
+ const BUN: Requirement = {
20
+ name: "bun",
21
+ check: ["bun", "--version"],
22
+ install: [{ via: "bootstrap", tool: "bun" }],
23
+ };
24
+ const declares = (bin: Requirement[]) => ({
25
+ name: "p",
26
+ requires: { schema: 1, bin },
27
+ });
44
28
 
45
- test("no requires / non-array bin → empty object", () => {
46
- expect(parsePluginRequires({ name: "x" })).toEqual({});
47
- expect(parsePluginRequires({ requires: {} })).toEqual({});
48
- expect(parsePluginRequires({ requires: { bin: "nope" } })).toEqual({});
49
- expect(parsePluginRequires(null)).toEqual({});
50
- });
29
+ let dir: string;
30
+ beforeEach(async () => {
31
+ dir = await fs.realpath(await mkdtemp(join(tmpdir(), "plugreq-")));
32
+ });
33
+ afterEach(async () => {
34
+ await rm(dir, { recursive: true, force: true });
51
35
  });
52
36
 
53
- describe("parsePluginRequires — setup block (the established convention)", () => {
54
- test("terminal's setup: go module + required system tool", () => {
55
- // The real plugins/terminal/plugin.json shape.
56
- const r = parsePluginRequires({
57
- name: "terminal",
58
- setup: {
59
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
60
- required: ["tmux"],
61
- },
37
+ describe("readPluginRequires", () => {
38
+ test("parses .claude-plugin/plugin.json through requires-probe", async () => {
39
+ await fs.outputJson(
40
+ join(dir, ".claude-plugin", "plugin.json"),
41
+ declares([BUN]),
42
+ );
43
+ expect(await readPluginRequires(dir)).toEqual({
44
+ ok: true,
45
+ requirements: [BUN],
62
46
  });
63
- const mcp = r.bin!.find((b) => b.name === "tmux-mcp")!;
64
- expect(mcp.via).toBe("go");
65
- expect(mcp.module).toBe("github.com/MadAppGang/tmux-mcp");
66
- expect(mcp.version).toBe("latest");
67
- const tmux = r.bin!.find((b) => b.name === "tmux")!;
68
- expect(tmux.via).toBe("brew");
69
- expect(tmux.formula).toBe("tmux");
70
47
  });
71
48
 
72
- // Negative control for the /vN fix: basename(base) yields "v2" here, so
73
- // without the go-module helper this test FAILS on `name`.
74
- test("terminal's v2 pin: /v2 is the module's major version, not the binary", () => {
75
- const r = parsePluginRequires({
76
- name: "terminal",
49
+ test("a setup block is read by nothing: without requires the plugin declares nothing", async () => {
50
+ await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
51
+ name: "p",
77
52
  setup: { go: ["github.com/MadAppGang/tmux-mcp/v2@v2.0.0"] },
78
53
  });
79
- expect(r.bin).toEqual([
80
- {
81
- name: "tmux-mcp",
82
- via: "go",
83
- module: "github.com/MadAppGang/tmux-mcp/v2",
84
- version: "v2.0.0",
85
- check: "tmux-mcp --version",
86
- },
87
- ]);
88
- });
89
-
90
- // One manifest per spec: entries are keyed by binary name, so a v1 and a v2
91
- // pin of the same module in ONE setup would (correctly) collapse to one.
92
- test.each([
93
- [
94
- "github.com/MadAppGang/tmux-mcp@v1.7.1",
95
- {
96
- name: "tmux-mcp",
97
- module: "github.com/MadAppGang/tmux-mcp",
98
- version: "v1.7.1",
99
- },
100
- ],
101
- [
102
- "github.com/MadAppGang/tmux-mcp/v2",
103
- {
104
- name: "tmux-mcp",
105
- module: "github.com/MadAppGang/tmux-mcp/v2",
106
- version: undefined,
107
- },
108
- ],
109
- [
110
- "github.com/x/v2tool@v1.0.0",
111
- { name: "v2tool", module: "github.com/x/v2tool", version: "v1.0.0" },
112
- ],
113
- ])("go spec %s → name/module/version", (spec, expected) => {
114
- const r = parsePluginRequires({ setup: { go: [spec] } });
115
- expect(
116
- r.bin!.map((b) => ({
117
- name: b.name,
118
- module: b.module,
119
- version: b.version,
120
- })),
121
- ).toEqual([expected]);
122
- });
123
-
124
- test("browser-use's setup: pip packages", () => {
125
- const r = parsePluginRequires({
126
- name: "browser-use",
127
- setup: { pip: ["browser-use", "mcp"] },
54
+ expect(await readPluginRequires(dir)).toEqual({
55
+ ok: true,
56
+ requirements: [],
128
57
  });
129
- expect(r.bin!.map((b) => b.name).sort()).toEqual(["browser-use", "mcp"]);
130
- expect(r.bin!.every((b) => b.via === "pip")).toBe(true);
131
58
  });
132
59
 
133
- test("bun/npm specs split name@version", () => {
134
- const r = parsePluginRequires({
135
- setup: { bun: ["claudish@1.2.0"], npm: ["@scope/tool@3.0.0"] },
60
+ test("an invalid declaration is reported, never dropped to empty", async () => {
61
+ await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
62
+ name: "p",
63
+ requires: { schema: 1, bin: [{ name: "x", via: "brew" }] },
136
64
  });
137
- const claudish = r.bin!.find((b) => b.name === "claudish")!;
138
- expect(claudish.version).toBe("1.2.0");
139
- const scoped = r.bin!.find((b) => b.name === "tool")!;
140
- expect(scoped.package).toBe("@scope/tool");
141
- expect(scoped.version).toBe("3.0.0");
65
+ const r = await readPluginRequires(dir);
66
+ expect(r?.ok).toBe(false);
142
67
  });
143
68
 
144
- test("explicit requires.bin overrides a setup entry of the same name", () => {
145
- const r = parsePluginRequires({
146
- setup: { required: ["tmux"] }, // → brew
147
- requires: {
148
- bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }],
149
- },
150
- });
151
- const tmux = r.bin!.find((b) => b.name === "tmux")!;
152
- expect(tmux.via).toBe("go"); // requires.bin won
69
+ test("no manifest → null", async () => {
70
+ expect(await readPluginRequires(dir)).toBeNull();
153
71
  });
154
72
  });
155
73
 
156
- describe("readPluginRequires", () => {
157
- let dir: string;
158
- beforeEach(async () => {
159
- dir = await mkdtemp(join(tmpdir(), "plugreq-"));
160
- });
161
- afterEach(async () => {
162
- await rm(dir, { recursive: true, force: true });
163
- });
74
+ describe("resolvePluginRequires reads the copy Claude Code loads", () => {
75
+ test("the installed copy wins over a newer marketplace clone", async () => {
76
+ const root = join(dir, "plugins-root");
77
+ const installed = join(root, "cache", "fixture", "app", "1.0.0");
78
+ const clone = join(dir, "clone", "app");
79
+ await fs.outputJson(
80
+ join(installed, ".claude-plugin", "plugin.json"),
81
+ declares([BUN]),
82
+ );
83
+ await fs.outputJson(join(clone, ".claude-plugin", "plugin.json"), {
84
+ name: "app",
85
+ });
86
+ await fs.outputJson(join(root, "installed_plugins.json"), {
87
+ version: 2,
88
+ plugins: {
89
+ "app@fixture": [
90
+ {
91
+ scope: "project",
92
+ projectPath: dir,
93
+ installPath: installed,
94
+ version: "1.0.0",
95
+ installedAt: "2026-01-01T00:00:00.000Z",
96
+ lastUpdated: "2026-01-01T00:00:00.000Z",
97
+ },
98
+ ],
99
+ },
100
+ });
101
+ const found = await resolvePluginRequires("app@fixture", dir, {
102
+ root,
103
+ clone: async () => clone,
104
+ });
105
+ expect(found?.dir).toBe(installed);
106
+ expect(found?.parse).toEqual({ ok: true, requirements: [BUN] });
164
107
 
165
- test("reads plugin.json at the plugin root", async () => {
166
- await fs.writeJson(join(dir, "plugin.json"), {
167
- name: "p",
168
- requires: { bin: [{ name: "tmux", via: "brew" }] },
108
+ const notInstalled = await resolvePluginRequires("app@fixture", dir, {
109
+ root: join(dir, "no-registry"),
110
+ clone: async () => clone,
169
111
  });
170
- const r = await readPluginRequires(dir);
171
- expect(r.bin?.[0]?.name).toBe("tmux");
112
+ expect(notInstalled?.dir).toBe(clone);
172
113
  });
114
+ });
173
115
 
174
- test("falls back to .claude-plugin/plugin.json", async () => {
175
- await fs.ensureDir(join(dir, ".claude-plugin"));
176
- await fs.writeJson(join(dir, ".claude-plugin", "plugin.json"), {
177
- name: "p",
178
- requires: { bin: [{ name: "mnemex", via: "bun" }] },
179
- });
180
- const r = await readPluginRequires(dir);
181
- expect(r.bin?.[0]?.name).toBe("mnemex");
116
+ describe("selectBins", () => {
117
+ function pathWith(...tools: string[]): string {
118
+ const bin = join(dir, "bin");
119
+ mkdirSync(bin, { recursive: true });
120
+ for (const t of tools) {
121
+ writeFileSync(join(bin, t), "#!/bin/sh\nexit 0\n");
122
+ chmodSync(join(bin, t), 0o755);
123
+ }
124
+ return bin;
125
+ }
126
+
127
+ test("selects each requirement's first usable option, once, and records its root", () => {
128
+ const reqs: Requirement[] = [
129
+ BUN,
130
+ {
131
+ name: "claudish",
132
+ check: ["claudish", "--version"],
133
+ install: [{ via: "bun", package: "claudish", version: "6.0.0" }],
134
+ },
135
+ {
136
+ name: "jq",
137
+ check: ["jq", "--version"],
138
+ install: [{ via: "brew", formula: "jq" }],
139
+ },
140
+ {
141
+ name: "zsh",
142
+ check: ["zsh", "--version"],
143
+ install: [{ via: "apt", packages: ["zsh"] }],
144
+ },
145
+ ];
146
+ const selected = selectBins(reqs, "/plugin", pathWith("brew", "apt-get"));
147
+ expect(selected.map((b) => [b.name, b.chosen, b.root])).toEqual([
148
+ // Bun's installer needs curl and unzip, and this PATH has neither.
149
+ ["bun", null, "/plugin"],
150
+ [
151
+ "claudish",
152
+ { via: "bun", package: "claudish", version: "6.0.0" },
153
+ "/plugin",
154
+ ],
155
+ ["jq", { via: "brew", formula: "jq" }, "/plugin"],
156
+ ["zsh", { via: "apt", packages: ["zsh"] }, "/plugin"],
157
+ ]);
158
+ // The declaration itself is carried unchanged.
159
+ expect(selected[1]?.check).toEqual(["claudish", "--version"]);
182
160
  });
183
161
 
184
- test("missing manifest → empty", async () => {
185
- expect(await readPluginRequires(dir)).toEqual({});
162
+ test("a brew option is not selected without brew", () => {
163
+ const reqs: Requirement[] = [
164
+ {
165
+ name: "jq",
166
+ check: ["jq", "--version"],
167
+ install: [{ via: "brew", formula: "jq" }],
168
+ },
169
+ ];
170
+ expect(selectBins(reqs, undefined, pathWith())[0]?.chosen).toBeNull();
186
171
  });
187
172
  });