@stigmer/plugin-package 3.18.0-dev.20260918103812 → 3.18.1-dev.20260919070736
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/client/builtin.d.ts +40 -0
- package/client/builtin.d.ts.map +1 -0
- package/client/builtin.js +66 -0
- package/client/builtin.js.map +1 -0
- package/client/prepare.d.ts +99 -0
- package/client/prepare.d.ts.map +1 -0
- package/client/prepare.js +89 -0
- package/client/prepare.js.map +1 -0
- package/client/presentation.d.ts +19 -0
- package/client/presentation.d.ts.map +1 -0
- package/client/presentation.js +44 -0
- package/client/presentation.js.map +1 -0
- package/client/release.d.ts +16 -0
- package/client/release.d.ts.map +1 -0
- package/client/release.js +24 -0
- package/client/release.js.map +1 -0
- package/client/reroot.d.ts +21 -0
- package/client/reroot.d.ts.map +1 -0
- package/client/reroot.js +37 -0
- package/client/reroot.js.map +1 -0
- package/client.d.ts +16 -2
- package/client.d.ts.map +1 -1
- package/client.js +16 -2
- package/client.js.map +1 -1
- package/detect.d.ts +7 -0
- package/detect.d.ts.map +1 -1
- package/detect.js +7 -0
- package/detect.js.map +1 -1
- package/files.d.ts +7 -0
- package/files.d.ts.map +1 -1
- package/files.js +7 -0
- package/files.js.map +1 -1
- package/index.d.ts +4 -2
- package/index.d.ts.map +1 -1
- package/index.js +4 -2
- package/index.js.map +1 -1
- package/marketplace/read-marketplace.d.ts +13 -0
- package/marketplace/read-marketplace.d.ts.map +1 -1
- package/marketplace/read-marketplace.js +38 -1
- package/marketplace/read-marketplace.js.map +1 -1
- package/package.json +1 -1
- package/presentation.d.ts +52 -0
- package/presentation.d.ts.map +1 -0
- package/presentation.js +151 -0
- package/presentation.js.map +1 -0
- package/src/__tests__/client-prepare.test.ts +167 -0
- package/src/__tests__/marketplace.test.ts +13 -1
- package/src/__tests__/presentation.test.ts +161 -0
- package/src/client/builtin.ts +79 -0
- package/src/client/prepare.ts +160 -0
- package/src/client/presentation.ts +47 -0
- package/src/client/release.ts +25 -0
- package/src/client/reroot.ts +35 -0
- package/src/client.ts +29 -2
- package/src/detect.ts +8 -0
- package/src/files.ts +7 -0
- package/src/index.ts +4 -2
- package/src/marketplace/read-marketplace.ts +40 -1
- package/src/presentation.ts +165 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one preparation, reached three ways, and the small rules beside it.
|
|
3
|
+
*
|
|
4
|
+
* Pins: `preparePluginFromTree` over a lazily-read tree yields the CLI's
|
|
5
|
+
* recorded digest for the thermos fixture (the tripwire constant lives in
|
|
6
|
+
* `client-select-archive.test.ts`); it reads only the ignore files and the
|
|
7
|
+
* selected entries, never an ignored file's bytes; an over-cap selection is
|
|
8
|
+
* refused before any selected byte is read; a reader refusal is returned
|
|
9
|
+
* with the reader's findings. `isReleaseVersion`'s five cases, the two
|
|
10
|
+
* wrong answers the console and the CLI used to give among them.
|
|
11
|
+
* `rerootSingleDirectory` on the shapes a zip takes. The built-in sources'
|
|
12
|
+
* order, their reserved names, and that the official one is first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { BUILT_IN_MARKETPLACES, isBuiltInMarketplaceName } from "../client/builtin.js";
|
|
21
|
+
import { type LazyCandidate, preparePluginFromTree } from "../client/prepare.js";
|
|
22
|
+
import { OFFICIAL_MARKETPLACE_NAME } from "../client/refs.js";
|
|
23
|
+
import { isReleaseVersion } from "../client/release.js";
|
|
24
|
+
import { rerootSingleDirectory, stripDirectoryPrefix } from "../client/reroot.js";
|
|
25
|
+
|
|
26
|
+
const FIXTURES = fileURLToPath(new URL("./fixtures/cursor-plugins/", import.meta.url));
|
|
27
|
+
/** The CLI's digest for the thermos fixture, recorded in client-select-archive.test.ts. */
|
|
28
|
+
const THERMOS_DIGEST = "51bc4e5450e24762bb8515765c76bfe262f65c57f9afeda015c5818038396d5a";
|
|
29
|
+
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
const SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
32
|
+
|
|
33
|
+
function manifest(name: string): string {
|
|
34
|
+
return JSON.stringify({ $schema: SCHEMA, name, version: "1.0.0", description: `the ${name} plugin` });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A directory as a lazily-read tree, recording which paths were read. */
|
|
38
|
+
function lazyDirectory(root: string): { candidates: LazyCandidate[]; reads: string[] } {
|
|
39
|
+
const reads: string[] = [];
|
|
40
|
+
const candidates: LazyCandidate[] = [];
|
|
41
|
+
const walk = (dir: string, prefix: string): void => {
|
|
42
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
44
|
+
const full = `${dir}/${entry.name}`;
|
|
45
|
+
if (entry.isDirectory()) walk(full, path);
|
|
46
|
+
else if (entry.isFile()) {
|
|
47
|
+
candidates.push({
|
|
48
|
+
path,
|
|
49
|
+
size: statSync(full).size,
|
|
50
|
+
read: async () => {
|
|
51
|
+
reads.push(path);
|
|
52
|
+
return new Uint8Array(readFileSync(full));
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(root, "");
|
|
59
|
+
return { candidates, reads };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function lazyMemory(files: Record<string, string>): { candidates: LazyCandidate[]; reads: string[] } {
|
|
63
|
+
const reads: string[] = [];
|
|
64
|
+
const candidates = Object.entries(files).map(([path, text]) => {
|
|
65
|
+
const bytes = encoder.encode(text);
|
|
66
|
+
return {
|
|
67
|
+
path,
|
|
68
|
+
size: bytes.length,
|
|
69
|
+
read: async () => {
|
|
70
|
+
reads.push(path);
|
|
71
|
+
return bytes;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
return { candidates, reads };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("preparePluginFromTree", () => {
|
|
79
|
+
it("yields the CLI's digest for the thermos fixture from a lazily-read tree", async () => {
|
|
80
|
+
const { candidates } = lazyDirectory(`${FIXTURES}thermos`);
|
|
81
|
+
const outcome = await preparePluginFromTree(candidates, { respectGitignore: true });
|
|
82
|
+
expect(outcome.ok).toBe(true);
|
|
83
|
+
if (!outcome.ok) return;
|
|
84
|
+
expect(outcome.prepared.digest).toBe(THERMOS_DIGEST);
|
|
85
|
+
expect(outcome.prepared.plugin.name).toBe("thermos");
|
|
86
|
+
expect(outcome.prepared.stats.filesIncluded).toBe(10);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("reads the ignore file and the selected entries only, never an ignored file's bytes", async () => {
|
|
90
|
+
const tree = lazyMemory({
|
|
91
|
+
".gitignore": "secrets.txt\n",
|
|
92
|
+
"plugin.json": manifest("quiet"),
|
|
93
|
+
"node_modules/left-pad/index.js": "module.exports = 1;",
|
|
94
|
+
"secrets.txt": "hunter2",
|
|
95
|
+
"skills/greet/SKILL.md": "---\nname: greet\ndescription: says hello\n---\nHello.",
|
|
96
|
+
});
|
|
97
|
+
const outcome = await preparePluginFromTree(tree.candidates, { respectGitignore: true });
|
|
98
|
+
expect(outcome.ok).toBe(true);
|
|
99
|
+
expect(tree.reads.sort()).toEqual([".gitignore", "plugin.json", "skills/greet/SKILL.md"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("refuses an over-cap selection before a selected byte is read", async () => {
|
|
103
|
+
const tree = lazyMemory({
|
|
104
|
+
"plugin.json": manifest("heavy"),
|
|
105
|
+
"skills/big/SKILL.md": "x".repeat(2_000),
|
|
106
|
+
});
|
|
107
|
+
const outcome = await preparePluginFromTree(tree.candidates, { respectGitignore: true, maxBytes: 1_000 });
|
|
108
|
+
expect(outcome).toMatchObject({ ok: false, kind: "too-large", maxBytes: 1_000 });
|
|
109
|
+
if (outcome.ok || outcome.kind !== "too-large") return;
|
|
110
|
+
expect(outcome.selectedBytes).toBeGreaterThan(1_000);
|
|
111
|
+
expect(tree.reads).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns the reader's refusal with its findings when the tree is not a plugin", async () => {
|
|
115
|
+
const tree = lazyMemory({ "README.md": "not a plugin" });
|
|
116
|
+
const outcome = await preparePluginFromTree(tree.candidates, { respectGitignore: true });
|
|
117
|
+
expect(outcome.ok).toBe(false);
|
|
118
|
+
if (outcome.ok || outcome.kind !== "refused") throw new Error("expected the reader's refusal");
|
|
119
|
+
expect(outcome.errors.length).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("isReleaseVersion", () => {
|
|
124
|
+
it("accepts a release and a pre-release the lockstep publish covers", () => {
|
|
125
|
+
expect(isReleaseVersion("3.17.0")).toBe(true);
|
|
126
|
+
// The console used to call this a development build.
|
|
127
|
+
expect(isReleaseVersion("3.17.0-rc.1")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("refuses every shape a source or dev-channel build reports", () => {
|
|
131
|
+
// The CLI used to accept the bare stamp an unbundled server reports.
|
|
132
|
+
expect(isReleaseVersion("dev")).toBe(false);
|
|
133
|
+
expect(isReleaseVersion("0.0.0-dev")).toBe(false);
|
|
134
|
+
expect(isReleaseVersion("3.17.0-dev.20260918120000")).toBe(false);
|
|
135
|
+
expect(isReleaseVersion("3.17.0+build.5")).toBe(false);
|
|
136
|
+
expect(isReleaseVersion("")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("rerootSingleDirectory", () => {
|
|
141
|
+
it("names the one directory every path is under", () => {
|
|
142
|
+
const paths = ["my-plugin/plugin.json", "my-plugin/skills/a/SKILL.md", "my-plugin/.gitignore"];
|
|
143
|
+
expect(rerootSingleDirectory(paths)).toBe("my-plugin");
|
|
144
|
+
expect(stripDirectoryPrefix(paths, "my-plugin")).toEqual(["plugin.json", "skills/a/SKILL.md", ".gitignore"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("leaves a tree that is already rooted, or has two roots, alone", () => {
|
|
148
|
+
expect(rerootSingleDirectory(["plugin.json", "skills/a/SKILL.md"])).toBeNull();
|
|
149
|
+
expect(rerootSingleDirectory(["a/plugin.json", "b/plugin.json"])).toBeNull();
|
|
150
|
+
expect(rerootSingleDirectory(["a/plugin.json", "README.md"])).toBeNull();
|
|
151
|
+
expect(rerootSingleDirectory([])).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("BUILT_IN_MARKETPLACES", () => {
|
|
156
|
+
it("lists the official catalogue first, then the three vendors, each a reserved name", () => {
|
|
157
|
+
expect(BUILT_IN_MARKETPLACES.map((entry) => entry.name)).toEqual([
|
|
158
|
+
OFFICIAL_MARKETPLACE_NAME,
|
|
159
|
+
"cursor-plugins",
|
|
160
|
+
"claude-code-plugins",
|
|
161
|
+
"codex-plugins",
|
|
162
|
+
]);
|
|
163
|
+
expect(BUILT_IN_MARKETPLACES[0]?.source).toEqual({ type: "official" });
|
|
164
|
+
for (const entry of BUILT_IN_MARKETPLACES) expect(isBuiltInMarketplaceName(entry.name)).toBe(true);
|
|
165
|
+
expect(isBuiltInMarketplaceName("acme-plugins")).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -15,7 +15,7 @@ import { describe, expect, it } from "vitest";
|
|
|
15
15
|
|
|
16
16
|
import { inMemoryPluginFiles, PLUGIN_DOCUMENT_LIMITS, type PluginFileEntry, type PluginFiles } from "../files.js";
|
|
17
17
|
import type { Marketplace, MarketplaceErrorKind, MarketplaceFinding, MarketplaceReadOutcome, MarketplaceWarningKind } from "../marketplace/outcome.js";
|
|
18
|
-
import { hasMarketplaceFile, readMarketplace } from "../marketplace/read-marketplace.js";
|
|
18
|
+
import { hasMarketplaceFile, readMarketplace, readMarketplaceFile } from "../marketplace/read-marketplace.js";
|
|
19
19
|
import { cursorPlugin, openPlugin, type PluginFixture } from "../testing.js";
|
|
20
20
|
import { directoryPluginFiles } from "../__test-utils__/directory-files.js";
|
|
21
21
|
import { findingOf, type Kinds } from "../__test-utils__/read.js";
|
|
@@ -223,6 +223,18 @@ describe("the Cursor file, verbatim from cursor/plugins at c1c0a32, over a parti
|
|
|
223
223
|
expect(new Set(outcome.warnings.map((w) => w.kind))).toEqual(new Set(["entry-directory-missing"]));
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
it("read as a file alone, declares all seventy-nine with no directory warnings: the CLI's bare-name peek", () => {
|
|
227
|
+
const fileOnly = readMarketplaceFile(
|
|
228
|
+
inMemoryPluginFiles(new Map([[".cursor-plugin/marketplace.json", files.get(".cursor-plugin/marketplace.json")!]])),
|
|
229
|
+
);
|
|
230
|
+
const declared = accepted(fileOnly);
|
|
231
|
+
expect(declared.plugins).toHaveLength(79);
|
|
232
|
+
expect(declared.plugins.map((p) => p.name)).toContain("thermos");
|
|
233
|
+
expect(fileOnly.warnings.filter((w) => w.kind === "entry-directory-missing")).toEqual([]);
|
|
234
|
+
// Still refused for what the file itself gets wrong, exactly as the tree read is.
|
|
235
|
+
expect(readMarketplaceFile(inMemoryPluginFiles(new Map([["README.md", "x"]]))).ok).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
226
238
|
it("offers every entry when the whole catalogue is present (the sources all resolve inside the root)", () => {
|
|
227
239
|
// Every source directory given one manifest, so the file's own shape is
|
|
228
240
|
// proven independently of which plugins the fixtures vendor.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presentation read, the one a storefront card runs.
|
|
3
|
+
*
|
|
4
|
+
* Pins: the vendored Cursor fixtures yield their `displayName`, `logo`,
|
|
5
|
+
* `category` and author (thermos, a PNG; playwright, an SVG); a Codex
|
|
6
|
+
* manifest yields the same fields from `interface`, with the `./` spelling
|
|
7
|
+
* stripped; a Claude manifest and an open manifest yield identity only; a
|
|
8
|
+
* root manifest names the plugin ahead of a vendor manifest beside it,
|
|
9
|
+
* while the vendor manifest still lends its appearance; a logo that is
|
|
10
|
+
* absent from the listing, over the cap, not an image, a URL or an escape
|
|
11
|
+
* is dropped without a finding; an unparseable manifest contributes nothing
|
|
12
|
+
* and the read still answers. The lazy twin reads exactly the manifests
|
|
13
|
+
* listed and no other file, and treats a fetch that rejects as a manifest
|
|
14
|
+
* that is not there.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
import { type LazyCandidate } from "../client/prepare.js";
|
|
22
|
+
import { readPluginPresentationFromTree } from "../client/presentation.js";
|
|
23
|
+
import { inMemoryPluginFiles, PLUGIN_DOCUMENT_LIMITS } from "../files.js";
|
|
24
|
+
import { MANIFEST_LOCATIONS } from "../messages.js";
|
|
25
|
+
import { readPluginPresentation } from "../presentation.js";
|
|
26
|
+
import { claudePlugin, codexPlugin, cursorPlugin, openPlugin, withFile, type PluginFixture } from "../testing.js";
|
|
27
|
+
import { directoryPluginFiles } from "../__test-utils__/directory-files.js";
|
|
28
|
+
|
|
29
|
+
const FIXTURES = fileURLToPath(new URL("./fixtures/cursor-plugins/", import.meta.url));
|
|
30
|
+
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
31
|
+
|
|
32
|
+
function present(files: PluginFixture) {
|
|
33
|
+
return readPluginPresentation(inMemoryPluginFiles(files));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("readPluginPresentation over the vendored Cursor fixtures", () => {
|
|
37
|
+
it("thermos: display name, PNG logo, category, author", () => {
|
|
38
|
+
const presentation = readPluginPresentation(directoryPluginFiles(`${FIXTURES}thermos`));
|
|
39
|
+
expect(presentation).toEqual({
|
|
40
|
+
displayName: "Thermos",
|
|
41
|
+
logo: "assets/logo.png",
|
|
42
|
+
category: "developer-tools",
|
|
43
|
+
version: "1.0.0",
|
|
44
|
+
description: expect.stringContaining("Thermo-nuclear branch review"),
|
|
45
|
+
author: { name: "Cursor", email: "plugins@cursor.com" },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("playwright: an SVG logo is an image too", () => {
|
|
50
|
+
const presentation = readPluginPresentation(directoryPluginFiles(`${FIXTURES}playwright`));
|
|
51
|
+
expect(presentation.displayName).toBe("Playwright");
|
|
52
|
+
expect(presentation.logo).toBe("assets/logo.svg");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("the dialects that carry appearance", () => {
|
|
57
|
+
it("a Cursor manifest speaks at its top level", () => {
|
|
58
|
+
const files = withFile(
|
|
59
|
+
cursorPlugin({ manifest: { displayName: "GitHub", logo: "assets/logo.png", category: "integrations", author: { name: "Cursor" } } }),
|
|
60
|
+
"assets/logo.png",
|
|
61
|
+
PNG_BYTES,
|
|
62
|
+
);
|
|
63
|
+
expect(present(files)).toMatchObject({ displayName: "GitHub", logo: "assets/logo.png", category: "integrations", author: { name: "Cursor" } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("a legacy Codex manifest speaks through `interface`, and `./` is stripped from its logo", () => {
|
|
67
|
+
const files = withFile(
|
|
68
|
+
codexPlugin({
|
|
69
|
+
version: "6.0.1",
|
|
70
|
+
manifest: { interface: { displayName: "Airtable", logo: "./assets/logo.png", category: "Productivity", developerName: "Airtable" } },
|
|
71
|
+
}),
|
|
72
|
+
"assets/logo.png",
|
|
73
|
+
PNG_BYTES,
|
|
74
|
+
);
|
|
75
|
+
expect(present(files)).toEqual({ displayName: "Airtable", logo: "assets/logo.png", category: "Productivity", version: "6.0.1" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a Claude manifest and an open manifest yield identity only", () => {
|
|
79
|
+
expect(present(claudePlugin({ version: "1.0.0", description: "Reviews pull requests.", manifest: { author: { name: "Anthropic" } } }))).toEqual({
|
|
80
|
+
version: "1.0.0",
|
|
81
|
+
description: "Reviews pull requests.",
|
|
82
|
+
author: { name: "Anthropic" },
|
|
83
|
+
});
|
|
84
|
+
const open = present(openPlugin({ version: "2.0.0", description: "The assistant." }));
|
|
85
|
+
expect(open.displayName).toBeUndefined();
|
|
86
|
+
expect(open.logo).toBeUndefined();
|
|
87
|
+
expect(open.version).toBe("2.0.0");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("a root manifest names the plugin first; a vendor manifest beside it still lends its appearance", () => {
|
|
91
|
+
let files = openPlugin({ version: "3.0.0", description: "From the root manifest." });
|
|
92
|
+
files = withFile(
|
|
93
|
+
files,
|
|
94
|
+
MANIFEST_LOCATIONS.cursor,
|
|
95
|
+
JSON.stringify({ name: "plugin", version: "1.0.0", description: "From the Cursor manifest.", displayName: "Plugin", logo: "assets/logo.png" }),
|
|
96
|
+
);
|
|
97
|
+
files = withFile(files, "assets/logo.png", PNG_BYTES);
|
|
98
|
+
expect(present(files)).toMatchObject({ version: "3.0.0", description: "From the root manifest.", displayName: "Plugin", logo: "assets/logo.png" });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("a logo that cannot be shown is no logo, never a finding", () => {
|
|
103
|
+
const cases: readonly [string, string, PluginFixture | undefined][] = [
|
|
104
|
+
["not listed", "assets/missing.png", undefined],
|
|
105
|
+
["a URL", "https://example.com/logo.png", undefined],
|
|
106
|
+
["an escape", "../logo.png", undefined],
|
|
107
|
+
["not an image", "assets/logo.txt", withFile(cursorPlugin(), "assets/logo.txt", "text")],
|
|
108
|
+
["over the cap", "assets/huge.png", withFile(cursorPlugin(), "assets/huge.png", new Uint8Array(PLUGIN_DOCUMENT_LIMITS.logo + 1))],
|
|
109
|
+
];
|
|
110
|
+
for (const [label, logo, base] of cases) {
|
|
111
|
+
it(label, () => {
|
|
112
|
+
const files = withFile(base ?? cursorPlugin(), MANIFEST_LOCATIONS.cursor, JSON.stringify({ name: "plugin", displayName: "Plugin", logo }));
|
|
113
|
+
const presentation = present(files);
|
|
114
|
+
expect(presentation.displayName).toBe("Plugin");
|
|
115
|
+
expect(presentation.logo).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("a wrong type is absent", () => {
|
|
120
|
+
const files = withFile(cursorPlugin(), MANIFEST_LOCATIONS.cursor, JSON.stringify({ name: "plugin", displayName: 7, logo: ["assets/logo.png"], author: "Cursor" }));
|
|
121
|
+
expect(present(files)).toEqual({});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("an unparseable manifest contributes nothing and the read answers", () => {
|
|
125
|
+
const files = withFile(cursorPlugin({ version: "1.0.0" }), MANIFEST_LOCATIONS.cursor, "{ not json");
|
|
126
|
+
expect(present(files)).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("readPluginPresentationFromTree", () => {
|
|
131
|
+
function lazy(files: PluginFixture, reads: string[], failing: ReadonlySet<string> = new Set()): LazyCandidate[] {
|
|
132
|
+
const encoder = new TextEncoder();
|
|
133
|
+
return [...files.entries()].map(([path, content]) => {
|
|
134
|
+
const bytes = typeof content === "string" ? encoder.encode(content) : content;
|
|
135
|
+
return {
|
|
136
|
+
path,
|
|
137
|
+
size: bytes.length,
|
|
138
|
+
read: async () => {
|
|
139
|
+
reads.push(path);
|
|
140
|
+
if (failing.has(path)) throw new Error(`cannot read ${path}`);
|
|
141
|
+
return bytes;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it("reads exactly the manifests listed and no other file", async () => {
|
|
148
|
+
const reads: string[] = [];
|
|
149
|
+
const files = withFile(cursorPlugin({ manifest: { displayName: "Thermos", logo: "assets/logo.png" }, skills: [{ name: "review" }] }), "assets/logo.png", PNG_BYTES);
|
|
150
|
+
const presentation = await readPluginPresentationFromTree(lazy(files, reads));
|
|
151
|
+
expect(presentation).toMatchObject({ displayName: "Thermos", logo: "assets/logo.png" });
|
|
152
|
+
expect(reads).toEqual([MANIFEST_LOCATIONS.cursor]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("a manifest whose fetch rejects is a manifest that is not there", async () => {
|
|
156
|
+
const reads: string[] = [];
|
|
157
|
+
const files = cursorPlugin({ manifest: { displayName: "Thermos" } });
|
|
158
|
+
const presentation = await readPluginPresentationFromTree(lazy(files, reads, new Set([MANIFEST_LOCATIONS.cursor])));
|
|
159
|
+
expect(presentation).toEqual({});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sources every client lists without being told: Stigmer's official
|
|
3
|
+
* catalogue and the three vendors' public ones.
|
|
4
|
+
*
|
|
5
|
+
* The product's promise is "bring your Cursor, Claude Code or Codex
|
|
6
|
+
* plugin", so the catalogues those vendors publish are on offer from the
|
|
7
|
+
* first screen, in the console's Marketplace and in `stigmer marketplace
|
|
8
|
+
* list`, without a user having to know a repository slug. They are code,
|
|
9
|
+
* not stored state: a client's remembered sources hold only what the user
|
|
10
|
+
* added, so the two clients cannot drift from each other and a vendor
|
|
11
|
+
* moving its repository is one release, not a migration. The names are
|
|
12
|
+
* reserved the way the official one is; a user can neither add nor remove
|
|
13
|
+
* them.
|
|
14
|
+
*
|
|
15
|
+
* Each vendor keeps its marketplace file at the repository root in its own
|
|
16
|
+
* location, all four of which `readMarketplace` reads (`.cursor-plugin/`,
|
|
17
|
+
* `.claude-plugin/`, `.agents/plugins/`). Measured on 2026-09-18 through
|
|
18
|
+
* the GitHub Trees API: 1,168, 1,594 and 7,746 entries respectively, none
|
|
19
|
+
* truncated, all under the console's 20,000-entry listing cap; Codex's
|
|
20
|
+
* catalogue names its entries as `{source: "local", path}` objects, which
|
|
21
|
+
* the reader accepts, and three of its 65 as remote sources, which it
|
|
22
|
+
* drops with its own sentence.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { GitHubMarketplaceSource } from "./refs.js";
|
|
26
|
+
import { OFFICIAL_MARKETPLACE_NAME } from "./refs.js";
|
|
27
|
+
|
|
28
|
+
/** A source a client ships with: named, described for a section heading, and either the official catalogue or a public GitHub tree. */
|
|
29
|
+
export interface BuiltInMarketplace {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
/** One sentence for the Marketplace's section heading and `marketplace list`. */
|
|
32
|
+
readonly description: string;
|
|
33
|
+
readonly source: { readonly type: "official" } | GitHubMarketplaceSource;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** In listing order: the official catalogue first, then the vendors in the order the product names them. */
|
|
37
|
+
export const BUILT_IN_MARKETPLACES: readonly BuiltInMarketplace[] = [
|
|
38
|
+
{
|
|
39
|
+
name: OFFICIAL_MARKETPLACE_NAME,
|
|
40
|
+
description: "Stigmer's official catalogue, published with each release.",
|
|
41
|
+
source: { type: "official" },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "cursor-plugins",
|
|
45
|
+
description: "Cursor's public plugin catalogue.",
|
|
46
|
+
source: { type: "github", repo: "cursor/plugins" },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "claude-code-plugins",
|
|
50
|
+
description: "Claude Code's public plugin catalogue.",
|
|
51
|
+
source: { type: "github", repo: "anthropics/claude-code" },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "codex-plugins",
|
|
55
|
+
description: "Codex's public plugin catalogue.",
|
|
56
|
+
source: { type: "github", repo: "openai/plugins" },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const BUILT_IN_NAMES: ReadonlySet<string> = new Set(BUILT_IN_MARKETPLACES.map((entry) => entry.name));
|
|
61
|
+
|
|
62
|
+
/** Whether `name` is one a user may neither add nor remove. */
|
|
63
|
+
export function isBuiltInMarketplaceName(name: string): boolean {
|
|
64
|
+
return BUILT_IN_NAMES.has(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The one sentence every client refuses with when a user tries to add over or remove a built-in source. */
|
|
68
|
+
export function builtInSourceRefusal(name: string, act: "add" | "remove"): string {
|
|
69
|
+
switch (act) {
|
|
70
|
+
case "add":
|
|
71
|
+
return `'${name}' is a built-in source and cannot be added or replaced; choose another name`;
|
|
72
|
+
case "remove":
|
|
73
|
+
return `'${name}' is a built-in source and cannot be removed`;
|
|
74
|
+
default: {
|
|
75
|
+
const exhaustive: never = act;
|
|
76
|
+
return exhaustive;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preparation every client runs once it holds a tree: select the files
|
|
3
|
+
* a push carries, read the package and refuse what the reader refuses,
|
|
4
|
+
* archive, digest.
|
|
5
|
+
*
|
|
6
|
+
* Three clients arrive here with three kinds of tree: the CLI with a
|
|
7
|
+
* directory on disk, the console with a marketplace host's file listing,
|
|
8
|
+
* and the console again with a folder or a zip the user picked. What they
|
|
9
|
+
* do after selection is identical, and it is where the plugin's identity
|
|
10
|
+
* is made (the archive's bytes are `status.digest`), so it is written once.
|
|
11
|
+
* A parity test proves that one function reached three ways yields one
|
|
12
|
+
* digest for one tree; before this module each client carried its own copy
|
|
13
|
+
* of the chain and the test proved a coincidence.
|
|
14
|
+
*
|
|
15
|
+
* `preparePluginFromTree` is the lazy form for a tree whose bytes cost
|
|
16
|
+
* something to obtain (a fetch per file, a `File.arrayBuffer()`): it reads
|
|
17
|
+
* the two root ignore files, selects, refuses an over-cap selection before
|
|
18
|
+
* another byte moves, then reads only the files the archive will carry. A
|
|
19
|
+
* plugin folder holding a `node_modules/` costs its ignore file and its
|
|
20
|
+
* manifest, wherever the tree lives. The CLI keeps its own walk (a
|
|
21
|
+
* filesystem lets it skip a directory before listing it, which a flat tree
|
|
22
|
+
* cannot) and joins at `preparePluginArchive`.
|
|
23
|
+
*
|
|
24
|
+
* Refusals are returned, not thrown: each client renders the reader's
|
|
25
|
+
* findings in its own voice (the CLI's `pluginRefusal`, the console's
|
|
26
|
+
* `PluginReadRefusal`) and adds its own provenance around the result.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { PluginFiles } from "../files.js";
|
|
30
|
+
import type { PluginFinding } from "../outcome.js";
|
|
31
|
+
import { readPluginPackage } from "../read-plugin-package.js";
|
|
32
|
+
import type { PluginPackage } from "../types.js";
|
|
33
|
+
import { archivePlugin, digestArchive } from "./archive.js";
|
|
34
|
+
import {
|
|
35
|
+
type CandidateFile,
|
|
36
|
+
type SelectPluginFilesOptions,
|
|
37
|
+
type SelectionStats,
|
|
38
|
+
IGNORE_FILE_NAMES,
|
|
39
|
+
selectPluginFiles,
|
|
40
|
+
} from "./select.js";
|
|
41
|
+
|
|
42
|
+
/** What a client holds after preparation: the package as read, the bytes a push sends, and their identity. */
|
|
43
|
+
export interface PreparedPlugin {
|
|
44
|
+
readonly plugin: PluginPackage;
|
|
45
|
+
readonly warnings: readonly PluginFinding[];
|
|
46
|
+
/** The selected files, for the client that lists them (`push --dry-run`). */
|
|
47
|
+
readonly files: PluginFiles;
|
|
48
|
+
readonly stats: SelectionStats;
|
|
49
|
+
/** The exact bytes a push sends. */
|
|
50
|
+
readonly archive: Uint8Array;
|
|
51
|
+
/**
|
|
52
|
+
* SHA-256 of `archive`, lowercase hex: the identity the server records as
|
|
53
|
+
* `status.digest` (its digest is over the bytes it receives, and these are
|
|
54
|
+
* those bytes), so a client knows "already installed" without pushing.
|
|
55
|
+
*/
|
|
56
|
+
readonly digest: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What an already-selected tree can come to: a prepared plugin, or the reader's refusal. */
|
|
60
|
+
export type PrepareArchiveOutcome =
|
|
61
|
+
| { readonly ok: true; readonly prepared: PreparedPlugin }
|
|
62
|
+
/** The reader refused the package; `errors` are its sentences, as `stigmer validate -f` prints them. */
|
|
63
|
+
| {
|
|
64
|
+
readonly ok: false;
|
|
65
|
+
readonly kind: "refused";
|
|
66
|
+
readonly errors: readonly PluginFinding[];
|
|
67
|
+
readonly warnings: readonly PluginFinding[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** What a lazily-read tree can come to: the above, or a refusal on size before its bytes were read. */
|
|
71
|
+
export type PreparePluginOutcome =
|
|
72
|
+
| PrepareArchiveOutcome
|
|
73
|
+
/** The selected files exceed the caller's `maxBytes`; nothing beyond the ignore files was read. */
|
|
74
|
+
| { readonly ok: false; readonly kind: "too-large"; readonly selectedBytes: number; readonly maxBytes: number };
|
|
75
|
+
|
|
76
|
+
/** A tree the client can list now and read later: a candidate with the promise of its bytes. */
|
|
77
|
+
export interface LazyCandidate extends CandidateFile {
|
|
78
|
+
read(): Promise<Uint8Array>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PrepareFromTreeOptions extends SelectPluginFilesOptions {
|
|
82
|
+
/**
|
|
83
|
+
* The most bytes the SELECTED files may total, refused before they are
|
|
84
|
+
* read. A client states its own budget (the console's is the archive cap
|
|
85
|
+
* a browser will hold in memory); omitted, nothing is refused here and
|
|
86
|
+
* the server's zip gate is the only limit.
|
|
87
|
+
*/
|
|
88
|
+
readonly maxBytes?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read, refuse, archive and digest an already-selected tree. `selection`
|
|
93
|
+
* is what `selectPluginFiles` returns or what the CLI's walk builds in the
|
|
94
|
+
* same shape; its `files.read` must answer synchronously for every listed
|
|
95
|
+
* entry by now.
|
|
96
|
+
*/
|
|
97
|
+
export async function preparePluginArchive(selection: {
|
|
98
|
+
readonly files: PluginFiles;
|
|
99
|
+
readonly stats: SelectionStats;
|
|
100
|
+
}): Promise<PrepareArchiveOutcome> {
|
|
101
|
+
const outcome = readPluginPackage(selection.files);
|
|
102
|
+
if (!outcome.ok) {
|
|
103
|
+
return { ok: false, kind: "refused", errors: outcome.errors, warnings: outcome.warnings };
|
|
104
|
+
}
|
|
105
|
+
const archive = archivePlugin(selection.files);
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
prepared: {
|
|
109
|
+
plugin: outcome.plugin,
|
|
110
|
+
warnings: outcome.warnings,
|
|
111
|
+
files: selection.files,
|
|
112
|
+
stats: selection.stats,
|
|
113
|
+
archive,
|
|
114
|
+
digest: await digestArchive(archive),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prepare a tree whose bytes are obtained on demand. A byte is read only
|
|
121
|
+
* for a file that will be in the archive (plus the two root ignore files
|
|
122
|
+
* that decide which those are), and an over-cap selection is refused
|
|
123
|
+
* before any of them.
|
|
124
|
+
*/
|
|
125
|
+
export async function preparePluginFromTree(
|
|
126
|
+
candidates: readonly LazyCandidate[],
|
|
127
|
+
options: PrepareFromTreeOptions,
|
|
128
|
+
): Promise<PreparePluginOutcome> {
|
|
129
|
+
const byPath = new Map(candidates.map((candidate) => [candidate.path, candidate]));
|
|
130
|
+
const contents = new Map<string, Uint8Array>();
|
|
131
|
+
const fetchInto = async (path: string): Promise<void> => {
|
|
132
|
+
const candidate = byPath.get(path);
|
|
133
|
+
if (candidate === undefined) throw new Error(`plugin file '${path}' is not listed`);
|
|
134
|
+
contents.set(path, await candidate.read());
|
|
135
|
+
};
|
|
136
|
+
const read = (path: string): Uint8Array => {
|
|
137
|
+
const bytes = contents.get(path);
|
|
138
|
+
if (bytes === undefined) throw new Error(`plugin file '${path}' was not read before selection asked for it`);
|
|
139
|
+
return bytes;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Phase 1: only the two files that shape the selection.
|
|
143
|
+
await Promise.all(
|
|
144
|
+
[IGNORE_FILE_NAMES.gitignore, IGNORE_FILE_NAMES.stigmerignore]
|
|
145
|
+
.filter((name) => byPath.has(name))
|
|
146
|
+
.map((name) => fetchInto(name)),
|
|
147
|
+
);
|
|
148
|
+
const { maxBytes, ...selectOptions } = options;
|
|
149
|
+
const selection = selectPluginFiles(candidates, read, selectOptions);
|
|
150
|
+
|
|
151
|
+
if (maxBytes !== undefined && selection.stats.totalSize > maxBytes) {
|
|
152
|
+
return { ok: false, kind: "too-large", selectedBytes: selection.stats.totalSize, maxBytes };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Phase 2: exactly the files the archive carries.
|
|
156
|
+
await Promise.all(
|
|
157
|
+
selection.files.entries.filter((entry) => !contents.has(entry.path)).map((entry) => fetchInto(entry.path)),
|
|
158
|
+
);
|
|
159
|
+
return preparePluginArchive(selection);
|
|
160
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's presentation from a tree whose bytes cost something to obtain:
|
|
3
|
+
* the lazy twin of `readPluginPresentation`, as `preparePluginFromTree` is
|
|
4
|
+
* the lazy twin of the full read.
|
|
5
|
+
*
|
|
6
|
+
* A storefront lists a catalogue from one file and then shows a card per
|
|
7
|
+
* entry; the face on each card is in that entry's manifest, one small file
|
|
8
|
+
* in a directory the client has listed but not read. This reads exactly the
|
|
9
|
+
* manifest paths the listing shows (one, rarely two) and nothing else, then
|
|
10
|
+
* hands a `PluginFiles` whose `read` answers for those paths alone to the
|
|
11
|
+
* pure reader. A manifest that cannot be fetched contributes nothing, the
|
|
12
|
+
* pure reader's own rule for a manifest that cannot be parsed: a card is
|
|
13
|
+
* never refused, it is drawn with what arrived.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { comparePaths, type PluginFiles } from "../files.js";
|
|
17
|
+
import { MANIFEST_LOCATIONS } from "../messages.js";
|
|
18
|
+
import { type PluginPresentation, readPluginPresentation } from "../presentation.js";
|
|
19
|
+
import type { LazyCandidate } from "./prepare.js";
|
|
20
|
+
|
|
21
|
+
const MANIFEST_PATHS: ReadonlySet<string> = new Set(Object.values(MANIFEST_LOCATIONS));
|
|
22
|
+
|
|
23
|
+
/** The presentation of the plugin whose files `candidates` list, reading only its manifests. */
|
|
24
|
+
export async function readPluginPresentationFromTree(candidates: readonly LazyCandidate[]): Promise<PluginPresentation> {
|
|
25
|
+
const contents = new Map<string, Uint8Array>();
|
|
26
|
+
await Promise.all(
|
|
27
|
+
candidates
|
|
28
|
+
.filter((candidate) => MANIFEST_PATHS.has(candidate.path))
|
|
29
|
+
.map(async (candidate) => {
|
|
30
|
+
try {
|
|
31
|
+
contents.set(candidate.path, await candidate.read());
|
|
32
|
+
} catch {
|
|
33
|
+
// Unfetchable is unreadable: the pure reader skips a manifest it cannot open.
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
const files: PluginFiles = {
|
|
38
|
+
// `PluginFiles` promises a sorted listing; a host's tree order is its own.
|
|
39
|
+
entries: candidates.map(({ path, size }) => ({ path, size })).sort((a, b) => comparePaths(a.path, b.path)),
|
|
40
|
+
read: (path) => {
|
|
41
|
+
const bytes = contents.get(path);
|
|
42
|
+
if (bytes === undefined) throw new Error(`plugin file '${path}' was not fetched for presentation`);
|
|
43
|
+
return bytes;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
return readPluginPresentation(files);
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a version string names a release the lockstep publish covers.
|
|
3
|
+
*
|
|
4
|
+
* The official catalogue (`@stigmer/plugins`) and the runtime packages are
|
|
5
|
+
* published at every release version and at every pre-release the release
|
|
6
|
+
* lane cuts (`X.Y.Z-rc.1` under the `next` tag), never for a source build
|
|
7
|
+
* (`dev`, `0.0.0-dev`) or a dev-channel stamp (`X.Y.Z-dev.<stamp>`). Two
|
|
8
|
+
* clients used to answer this with two different tests: the console
|
|
9
|
+
* accepted only `X.Y.Z` and called an `rc` server a development build; the
|
|
10
|
+
* CLI accepted anything without `-dev` and would have tried to acquire
|
|
11
|
+
* packages for the bare `dev` an unbundled server reports. One predicate,
|
|
12
|
+
* five cases pinned, no third opinion.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A semver core, optionally followed by a pre-release whose first
|
|
17
|
+
* identifier is not `dev`. Build metadata (`+…`) is not accepted: nothing
|
|
18
|
+
* publishes with it.
|
|
19
|
+
*/
|
|
20
|
+
const RELEASE_PATTERN = /^\d+\.\d+\.\d+(?:-(?!dev(?:[.-]|$))[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
21
|
+
|
|
22
|
+
/** True for `3.17.0` and `3.17.0-rc.1`; false for `dev`, `0.0.0-dev` and `3.17.0-dev.20260918120000`. */
|
|
23
|
+
export function isReleaseVersion(version: string): boolean {
|
|
24
|
+
return RELEASE_PATTERN.test(version);
|
|
25
|
+
}
|