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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +897 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +1154 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +143 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- 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):
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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",
|
|
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({
|
|
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.
|
|
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
|
|
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: ["
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
package: PACKAGE,
|
|
267
|
-
check: "magus --version",
|
|
263
|
+
check: ["magus", "--version"],
|
|
264
|
+
chosen: { via: "bun", package: PACKAGE },
|
|
268
265
|
});
|
|
269
|
-
expect(bin.version).
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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("
|
|
54
|
-
test("
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
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("
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
138
|
-
expect(
|
|
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("
|
|
145
|
-
|
|
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("
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
expect(r.bin?.[0]?.name).toBe("tmux");
|
|
112
|
+
expect(notInstalled?.dir).toBe(clone);
|
|
172
113
|
});
|
|
114
|
+
});
|
|
173
115
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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("
|
|
185
|
-
|
|
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
|
});
|