@stigmer/plugin-package 3.34.0 → 3.36.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 (148) hide show
  1. package/client/archive.d.ts.map +1 -1
  2. package/client/archive.js.map +1 -1
  3. package/client/builtin.d.ts.map +1 -1
  4. package/client/builtin.js.map +1 -1
  5. package/client/ignore/defaults.d.ts.map +1 -1
  6. package/client/ignore/defaults.js.map +1 -1
  7. package/client/ignore/match.d.ts.map +1 -1
  8. package/client/ignore/match.js.map +1 -1
  9. package/client/ignore/matcher.d.ts.map +1 -1
  10. package/client/ignore/matcher.js.map +1 -1
  11. package/client/ignore/pattern.d.ts.map +1 -1
  12. package/client/ignore/pattern.js.map +1 -1
  13. package/client/prepare.d.ts.map +1 -1
  14. package/client/prepare.js.map +1 -1
  15. package/client/presentation.d.ts.map +1 -1
  16. package/client/presentation.js.map +1 -1
  17. package/client/refs.d.ts.map +1 -1
  18. package/client/refs.js.map +1 -1
  19. package/client/release.d.ts.map +1 -1
  20. package/client/release.js.map +1 -1
  21. package/client/reroot.d.ts.map +1 -1
  22. package/client/reroot.js.map +1 -1
  23. package/client/select.d.ts.map +1 -1
  24. package/client/select.js.map +1 -1
  25. package/client/vocabulary.d.ts.map +1 -1
  26. package/client/vocabulary.js.map +1 -1
  27. package/client.d.ts.map +1 -1
  28. package/client.js.map +1 -1
  29. package/detect.d.ts.map +1 -1
  30. package/detect.js.map +1 -1
  31. package/dialects/claude.d.ts.map +1 -1
  32. package/dialects/claude.js.map +1 -1
  33. package/dialects/codex.d.ts.map +1 -1
  34. package/dialects/codex.js.map +1 -1
  35. package/dialects/cursor.d.ts.map +1 -1
  36. package/dialects/cursor.js.map +1 -1
  37. package/dialects/manifest.d.ts.map +1 -1
  38. package/dialects/manifest.js.map +1 -1
  39. package/dialects/open.d.ts.map +1 -1
  40. package/dialects/open.js.map +1 -1
  41. package/documents.d.ts.map +1 -1
  42. package/documents.js.map +1 -1
  43. package/files.d.ts.map +1 -1
  44. package/files.js.map +1 -1
  45. package/frontmatter.d.ts.map +1 -1
  46. package/frontmatter.js.map +1 -1
  47. package/index.d.ts.map +1 -1
  48. package/index.js.map +1 -1
  49. package/marketplace/messages.d.ts.map +1 -1
  50. package/marketplace/messages.js.map +1 -1
  51. package/marketplace/outcome.d.ts.map +1 -1
  52. package/marketplace/outcome.js.map +1 -1
  53. package/marketplace/read-marketplace.d.ts.map +1 -1
  54. package/marketplace/read-marketplace.js.map +1 -1
  55. package/messages.d.ts.map +1 -1
  56. package/messages.js.map +1 -1
  57. package/normalise/ignored.d.ts.map +1 -1
  58. package/normalise/ignored.js.map +1 -1
  59. package/normalise/mcp-servers.d.ts.map +1 -1
  60. package/normalise/mcp-servers.js.map +1 -1
  61. package/normalise/overlay.d.ts.map +1 -1
  62. package/normalise/overlay.js.map +1 -1
  63. package/normalise/skills.d.ts.map +1 -1
  64. package/normalise/skills.js.map +1 -1
  65. package/normalise/sub-agents.d.ts.map +1 -1
  66. package/normalise/sub-agents.js.map +1 -1
  67. package/normalise/variables.d.ts.map +1 -1
  68. package/normalise/variables.js.map +1 -1
  69. package/outcome.d.ts.map +1 -1
  70. package/outcome.js.map +1 -1
  71. package/package.json +1 -1
  72. package/placeholders.d.ts.map +1 -1
  73. package/placeholders.js.map +1 -1
  74. package/presentation.d.ts.map +1 -1
  75. package/presentation.js.map +1 -1
  76. package/read-plugin-package.d.ts.map +1 -1
  77. package/read-plugin-package.js.map +1 -1
  78. package/testing.d.ts.map +1 -1
  79. package/testing.js.map +1 -1
  80. package/types.d.ts.map +1 -1
  81. package/types.js.map +1 -1
  82. package/src/__test-utils__/directory-files.ts +0 -29
  83. package/src/__test-utils__/read.ts +0 -55
  84. package/src/__tests__/adversarial.test.ts +0 -434
  85. package/src/__tests__/client-ignore.test.ts +0 -133
  86. package/src/__tests__/client-prepare.test.ts +0 -163
  87. package/src/__tests__/client-refs.test.ts +0 -101
  88. package/src/__tests__/client-select-archive.test.ts +0 -150
  89. package/src/__tests__/detect.test.ts +0 -144
  90. package/src/__tests__/files.test.ts +0 -119
  91. package/src/__tests__/fixtures/cursor-plugins/.cursor-plugin/marketplace.json +0 -412
  92. package/src/__tests__/fixtures/cursor-plugins/NOTICE +0 -27
  93. package/src/__tests__/fixtures/cursor-plugins/advisor/.cursor-plugin/plugin.json +0 -33
  94. package/src/__tests__/fixtures/cursor-plugins/advisor/CHANGELOG.md +0 -8
  95. package/src/__tests__/fixtures/cursor-plugins/advisor/LICENSE +0 -21
  96. package/src/__tests__/fixtures/cursor-plugins/advisor/README.md +0 -87
  97. package/src/__tests__/fixtures/cursor-plugins/advisor/agents/advisor-subagent.md +0 -48
  98. package/src/__tests__/fixtures/cursor-plugins/advisor/assets/avatar.png +0 -0
  99. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/capture-response.sh +0 -20
  100. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/hooks.json +0 -27
  101. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/lib.sh +0 -61
  102. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/mark-pending.sh +0 -27
  103. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/record-consult.sh +0 -41
  104. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/stop-hook.sh +0 -48
  105. package/src/__tests__/fixtures/cursor-plugins/advisor/skills/advisor/SKILL.md +0 -123
  106. package/src/__tests__/fixtures/cursor-plugins/advisor/skills/advisor/references/briefing-template.md +0 -44
  107. package/src/__tests__/fixtures/cursor-plugins/github/.cursor-plugin/plugin.json +0 -45
  108. package/src/__tests__/fixtures/cursor-plugins/github/CHANGELOG.md +0 -9
  109. package/src/__tests__/fixtures/cursor-plugins/github/LICENSE +0 -21
  110. package/src/__tests__/fixtures/cursor-plugins/github/README.md +0 -64
  111. package/src/__tests__/fixtures/cursor-plugins/github/assets/logo.svg +0 -0
  112. package/src/__tests__/fixtures/cursor-plugins/github/mcp.json +0 -11
  113. package/src/__tests__/fixtures/cursor-plugins/playwright/.cursor-plugin/plugin.json +0 -35
  114. package/src/__tests__/fixtures/cursor-plugins/playwright/CHANGELOG.md +0 -8
  115. package/src/__tests__/fixtures/cursor-plugins/playwright/LICENSE +0 -21
  116. package/src/__tests__/fixtures/cursor-plugins/playwright/README.md +0 -46
  117. package/src/__tests__/fixtures/cursor-plugins/playwright/assets/logo.svg +0 -0
  118. package/src/__tests__/fixtures/cursor-plugins/playwright/mcp.json +0 -8
  119. package/src/__tests__/fixtures/cursor-plugins/salesforce/.cursor-plugin/plugin.json +0 -48
  120. package/src/__tests__/fixtures/cursor-plugins/salesforce/CHANGELOG.md +0 -10
  121. package/src/__tests__/fixtures/cursor-plugins/salesforce/LICENSE +0 -21
  122. package/src/__tests__/fixtures/cursor-plugins/salesforce/README.md +0 -95
  123. package/src/__tests__/fixtures/cursor-plugins/salesforce/assets/logo.svg +0 -0
  124. package/src/__tests__/fixtures/cursor-plugins/salesforce/mcp.json +0 -12
  125. package/src/__tests__/fixtures/cursor-plugins/thermos/.cursor-plugin/plugin.json +0 -32
  126. package/src/__tests__/fixtures/cursor-plugins/thermos/CHANGELOG.md +0 -8
  127. package/src/__tests__/fixtures/cursor-plugins/thermos/LICENSE +0 -21
  128. package/src/__tests__/fixtures/cursor-plugins/thermos/README.md +0 -70
  129. package/src/__tests__/fixtures/cursor-plugins/thermos/agents/thermo-nuclear-code-quality-review-subagent.md +0 -23
  130. package/src/__tests__/fixtures/cursor-plugins/thermos/agents/thermo-nuclear-review-subagent.md +0 -28
  131. package/src/__tests__/fixtures/cursor-plugins/thermos/assets/logo.png +0 -0
  132. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermo-nuclear-code-quality-review/SKILL.md +0 -192
  133. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermo-nuclear-review/SKILL.md +0 -51
  134. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermos/SKILL.md +0 -21
  135. package/src/__tests__/fixtures/cursor-plugins/xero/.cursor-plugin/plugin.json +0 -53
  136. package/src/__tests__/fixtures/cursor-plugins/xero/CHANGELOG.md +0 -9
  137. package/src/__tests__/fixtures/cursor-plugins/xero/LICENSE +0 -21
  138. package/src/__tests__/fixtures/cursor-plugins/xero/README.md +0 -79
  139. package/src/__tests__/fixtures/cursor-plugins/xero/assets/logo.png +0 -0
  140. package/src/__tests__/fixtures/cursor-plugins/xero/mcp.json +0 -16
  141. package/src/__tests__/fixtures.test.ts +0 -198
  142. package/src/__tests__/marketplace.test.ts +0 -378
  143. package/src/__tests__/mcp-servers.test.ts +0 -120
  144. package/src/__tests__/overlay-and-ignored.test.ts +0 -65
  145. package/src/__tests__/presentation.test.ts +0 -186
  146. package/src/__tests__/skills.test.ts +0 -89
  147. package/src/__tests__/sub-agents.test.ts +0 -111
  148. package/src/__tests__/variables.test.ts +0 -94
@@ -1,163 +0,0 @@
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 alone, a reserved name; a vendor's repository is a source the user adds", () => {
157
- expect(BUILT_IN_MARKETPLACES.map((entry) => entry.name)).toEqual([OFFICIAL_MARKETPLACE_NAME]);
158
- expect(BUILT_IN_MARKETPLACES[0]?.source).toEqual({ type: "official" });
159
- for (const entry of BUILT_IN_MARKETPLACES) expect(isBuiltInMarketplaceName(entry.name)).toBe(true);
160
- expect(isBuiltInMarketplaceName("cursor-plugins")).toBe(false);
161
- expect(isBuiltInMarketplaceName("acme-plugins")).toBe(false);
162
- });
163
- });
@@ -1,101 +0,0 @@
1
- /**
2
- * The two grammars every client parses: an install ref and a GitHub
3
- * marketplace source. Pins the splits (`/` then `@`), the path refusal that
4
- * gets its own kind so a client can name its folder command, the URL forms
5
- * that normalise to `owner/repo[@ref]`, and that a refusal is returned with
6
- * a sentence rather than thrown.
7
- */
8
-
9
- import { describe, expect, it } from "vitest";
10
-
11
- import {
12
- describeGitHubSource,
13
- formatInstallRef,
14
- isOwnerRepo,
15
- looksLikePath,
16
- parseGitHubSource,
17
- parseInstallRef,
18
- } from "../client/refs.js";
19
-
20
- describe("parseInstallRef", () => {
21
- it("a bare name", () => {
22
- expect(parseInstallRef("thermos")).toEqual({ ok: true, ref: { name: "thermos" } });
23
- });
24
-
25
- it("marketplace/name@version", () => {
26
- expect(parseInstallRef("cursor-plugins/thermos@1.0.0")).toEqual({
27
- ok: true,
28
- ref: { marketplace: "cursor-plugins", name: "thermos", version: "1.0.0" },
29
- });
30
- });
31
-
32
- it("refuses a path with its own kind", () => {
33
- for (const text of ["./thermos", "../x", ".", "..", "/tmp/x", "~/x", "C:\\x", "a\\b"]) {
34
- const outcome = parseInstallRef(text);
35
- expect(outcome.ok).toBe(false);
36
- if (!outcome.ok) expect(outcome.kind).toBe("path");
37
- }
38
- });
39
-
40
- it("refuses a bad marketplace name, version and plugin name with the shape kind", () => {
41
- const bad = ["Upper/thermos", "thermos@", "thermos@1.0!", "Thermos", "a/b/c"];
42
- for (const text of bad) {
43
- const outcome = parseInstallRef(text);
44
- expect(outcome.ok).toBe(false);
45
- if (!outcome.ok) expect(outcome.kind).toBe("shape");
46
- }
47
- });
48
-
49
- it("formatInstallRef is the inverse", () => {
50
- const outcome = parseInstallRef("m/n@v");
51
- if (!outcome.ok) throw new Error("expected ok");
52
- expect(formatInstallRef(outcome.ref)).toBe("m/n@v");
53
- expect(formatInstallRef({ name: "n" })).toBe("n");
54
- });
55
- });
56
-
57
- describe("parseGitHubSource", () => {
58
- it("owner/repo and owner/repo@ref", () => {
59
- expect(parseGitHubSource("cursor/plugins")).toEqual({ ok: true, source: { type: "github", repo: "cursor/plugins" } });
60
- expect(parseGitHubSource("cursor/plugins@main")).toEqual({
61
- ok: true,
62
- source: { type: "github", repo: "cursor/plugins", ref: "main" },
63
- });
64
- });
65
-
66
- it("the browser's URL, with and without /tree/<ref> and .git", () => {
67
- for (const text of ["https://github.com/cursor/plugins", "https://www.github.com/cursor/plugins.git", "http://github.com/cursor/plugins/"]) {
68
- expect(parseGitHubSource(text)).toEqual({ ok: true, source: { type: "github", repo: "cursor/plugins" } });
69
- }
70
- expect(parseGitHubSource("https://github.com/cursor/plugins/tree/v1.2")).toEqual({
71
- ok: true,
72
- source: { type: "github", repo: "cursor/plugins", ref: "v1.2" },
73
- });
74
- });
75
-
76
- it("refuses what is not a repository", () => {
77
- for (const text of ["cursor", "cursor/plugins@", "a/b/c", "https://gitlab.com/a/b", "../x"]) {
78
- expect(parseGitHubSource(text).ok).toBe(false);
79
- }
80
- });
81
-
82
- it("describes a source in one phrase", () => {
83
- expect(describeGitHubSource({ type: "github", repo: "a/b" })).toBe("github.com/a/b");
84
- expect(describeGitHubSource({ type: "github", repo: "a/b", ref: "r" })).toBe("github.com/a/b@r");
85
- });
86
- });
87
-
88
- describe("isOwnerRepo and looksLikePath", () => {
89
- it("two segments of GitHub's character set", () => {
90
- expect(isOwnerRepo("a/b")).toBe(true);
91
- expect(isOwnerRepo("a.b/c-d_e")).toBe(true);
92
- expect(isOwnerRepo("./b")).toBe(false);
93
- expect(isOwnerRepo("a/../b")).toBe(false);
94
- expect(isOwnerRepo("-a/b")).toBe(false);
95
- });
96
-
97
- it("a path on any platform", () => {
98
- expect(looksLikePath("D:\\plugins")).toBe(true);
99
- expect(looksLikePath("thermos")).toBe(false);
100
- });
101
- });
@@ -1,150 +0,0 @@
1
- /**
2
- * The client entry's identity discipline: the same tree selected and
3
- * archived from a directory (the CLI's edge) and from a flat in-memory file
4
- * list (the console's edge) yields one digest, and that digest is the one
5
- * the CLI produced before this module existed.
6
- *
7
- * The frozen constant below was computed with the unmodified CLI at
8
- * `d75b0724a` (`preparePluginPush` over the vendored `thermos` fixture,
9
- * whose bytes never change). If it changes, every installed plugin would
10
- * look like an upgrade; this test is the tripwire. The catalogue's own
11
- * `plugins/linear` is read too, but its content is allowed to evolve (a
12
- * manifest edit is an upgrade by design), so its cases pin the identity
13
- * discipline (the directory edge and the in-memory edge agree on one
14
- * digest, and the selection is exactly its files) rather than a constant.
15
- *
16
- * The selection cases pin the walk order (component-wise, directories and
17
- * files interleaved by name), the whole-subtree skip of an ignored
18
- * directory, the root `.gitignore` and `.stigmerignore`, and that a
19
- * negation inside a skipped directory cannot pull a file back.
20
- */
21
-
22
- import { readdirSync, readFileSync, statSync } from "node:fs";
23
- import { fileURLToPath } from "node:url";
24
-
25
- import { describe, expect, it } from "vitest";
26
-
27
- import { archivePlugin, digestArchive } from "../client/archive.js";
28
- import { type CandidateFile, compareWalkOrder, selectPluginFiles } from "../client/select.js";
29
-
30
- const FIXTURES = fileURLToPath(new URL("./fixtures/cursor-plugins/", import.meta.url));
31
- const REPO_PLUGINS = fileURLToPath(new URL("../../../../../../plugins/", import.meta.url));
32
-
33
- const BASELINE = {
34
- thermos: "51bc4e5450e24762bb8515765c76bfe262f65c57f9afeda015c5818038396d5a",
35
- } as const;
36
-
37
- const encoder = new TextEncoder();
38
-
39
- /** Every file under `root`, unordered, the shape a hosted tree listing has. */
40
- function listDirectory(root: string): { candidates: CandidateFile[]; read: (path: string) => Uint8Array } {
41
- const candidates: CandidateFile[] = [];
42
- const walk = (dir: string, prefix: string): void => {
43
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
44
- const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
45
- const full = `${dir}/${entry.name}`;
46
- if (entry.isDirectory()) walk(full, path);
47
- else if (entry.isFile()) candidates.push({ path, size: statSync(full).size });
48
- }
49
- };
50
- walk(root, "");
51
- // Reverse so the test proves selection imposes the order, not the listing.
52
- candidates.reverse();
53
- return { candidates, read: (path) => new Uint8Array(readFileSync(`${root}/${path}`)) };
54
- }
55
-
56
- function inMemory(files: Record<string, string>): { candidates: CandidateFile[]; read: (path: string) => Uint8Array } {
57
- const bytes = new Map(Object.entries(files).map(([path, text]) => [path, encoder.encode(text)]));
58
- return {
59
- candidates: [...bytes].map(([path, content]) => ({ path, size: content.length })),
60
- read: (path) => {
61
- const content = bytes.get(path);
62
- if (content === undefined) throw new Error(`unlisted ${path}`);
63
- return content;
64
- },
65
- };
66
- }
67
-
68
- const RESPECT = { respectGitignore: true };
69
-
70
- describe("parity with the CLI's original walk", () => {
71
- it("plugins/linear selects exactly its files, in walk order", () => {
72
- const { candidates, read } = listDirectory(`${REPO_PLUGINS}linear`);
73
- const selection = selectPluginFiles(candidates, read, RESPECT);
74
- expect(selection.files.entries.map((entry) => entry.path)).toEqual(["mcp.json", "plugin.json"]);
75
- });
76
-
77
- it("the thermos fixture archives to the CLI's digest", async () => {
78
- const { candidates, read } = listDirectory(`${FIXTURES}thermos`);
79
- const selection = selectPluginFiles(candidates, read, RESPECT);
80
- expect(selection.stats.filesIncluded).toBe(10);
81
- expect(await digestArchive(archivePlugin(selection.files))).toBe(BASELINE.thermos);
82
- });
83
-
84
- it("the same bytes from a directory and from an in-memory listing yield one digest", async () => {
85
- const dir = listDirectory(`${REPO_PLUGINS}linear`);
86
- const fromDirectory = await digestArchive(archivePlugin(selectPluginFiles(dir.candidates, dir.read, RESPECT).files));
87
- const memory = inMemory(
88
- Object.fromEntries(dir.candidates.map((c) => [c.path, new TextDecoder().decode(dir.read(c.path))])),
89
- );
90
- const fromMemory = await digestArchive(archivePlugin(selectPluginFiles(memory.candidates, memory.read, RESPECT).files));
91
- expect(fromMemory).toBe(fromDirectory);
92
- expect(fromDirectory).toMatch(/^[0-9a-f]{64}$/);
93
- });
94
- });
95
-
96
- describe("compareWalkOrder", () => {
97
- it("visits a directory before a file that sorts after it by name", () => {
98
- // A code-point sort of full paths would put "b.txt" before "b/x.txt"
99
- // ('.' < '/'); the walk visits the directory `b` first.
100
- expect(["b.txt", "b/x.txt", "a.txt"].sort(compareWalkOrder)).toEqual(["a.txt", "b/x.txt", "b.txt"]);
101
- });
102
-
103
- it("orders siblings by code point, the CLI's comparison", () => {
104
- expect(["B.md", "a.md", "_x.md"].sort(compareWalkOrder)).toEqual(["B.md", "_x.md", "a.md"]);
105
- });
106
- });
107
-
108
- describe("selectPluginFiles", () => {
109
- it("skips an ignored directory whole, and a negation inside it cannot pull a file back", () => {
110
- const { candidates, read } = inMemory({
111
- "plugin.json": "{}",
112
- ".gitignore": "build/\n!build/keep.txt\n",
113
- "build/keep.txt": "x",
114
- "build/out.js": "y",
115
- "src/a.ts": "z",
116
- });
117
- const selection = selectPluginFiles(candidates, read, RESPECT);
118
- expect(selection.files.entries.map((e) => e.path)).toEqual([".gitignore", "plugin.json", "src/a.ts"]);
119
- expect(selection.stats).toEqual({ filesIncluded: 3, filesIgnored: 0, dirsSkipped: 1, totalSize: 26 });
120
- });
121
-
122
- it("applies the security defaults and lets .env.example through", () => {
123
- const { candidates, read } = inMemory({ "plugin.json": "{}", ".env": "S=1", ".env.example": "S=" });
124
- const selection = selectPluginFiles(candidates, read, RESPECT);
125
- expect(selection.files.entries.map((e) => e.path)).toEqual([".env.example", "plugin.json"]);
126
- expect(selection.stats.filesIgnored).toBe(1);
127
- });
128
-
129
- it("ignores .gitignore when told to, and always reads .stigmerignore", () => {
130
- const files = { "plugin.json": "{}", ".gitignore": "*.draft\n", ".stigmerignore": "*.tmp\n", "a.draft": "", "b.tmp": "" };
131
- const loose = selectPluginFiles(inMemory(files).candidates, inMemory(files).read, { respectGitignore: false });
132
- expect(loose.files.entries.map((e) => e.path)).toContain("a.draft");
133
- expect(loose.files.entries.map((e) => e.path)).not.toContain("b.tmp");
134
- const strict = selectPluginFiles(inMemory(files).candidates, inMemory(files).read, RESPECT);
135
- expect(strict.files.entries.map((e) => e.path)).not.toContain("a.draft");
136
- });
137
-
138
- it("the selected reader refuses a path it did not list", () => {
139
- const { candidates, read } = inMemory({ "plugin.json": "{}" });
140
- const selection = selectPluginFiles(candidates, read, RESPECT);
141
- expect(() => selection.files.read("missing.md")).toThrow("plugin file 'missing.md' is not listed");
142
- });
143
- });
144
-
145
- describe("digestArchive", () => {
146
- it("is lowercase hex SHA-256", async () => {
147
- const digest = await digestArchive(new Uint8Array([1, 2, 3]));
148
- expect(digest).toBe("039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81");
149
- });
150
- });
@@ -1,144 +0,0 @@
1
- /**
2
- * Pins manifest detection and identity: the root manifest wins, the vendor
3
- * precedence without one, every present manifest contributes, name rules
4
- * and conflicts, the open manifest's `$schema` arms, and unknown fields as
5
- * warnings in every dialect.
6
- */
7
-
8
- import { describe, expect, it } from "vitest";
9
-
10
- import { isValidPluginName } from "../detect.js";
11
- import { claudePlugin, codexPlugin, cursorPlugin, openPlugin, withManifestField } from "../testing.js";
12
- import { accepted, findingOf, kindsOf, read, refused } from "../__test-utils__/read.js";
13
-
14
- describe("identity and precedence", () => {
15
- it("takes identity from the root manifest and lists it first", () => {
16
- const files = openPlugin({ name: "tools", version: "1.2.0", description: "d", manifest: { license: "MIT", keywords: ["a"] } });
17
- const plugin = accepted(read(files));
18
- expect(plugin).toMatchObject({ name: "tools", version: "1.2.0", description: "d", license: "MIT", keywords: ["a"], dialect: "agent-plugins" });
19
- expect(plugin.manifestsFound).toEqual(["plugin.json"]);
20
- });
21
-
22
- it("without a root manifest, Claude precedes Cursor precedes Codex", () => {
23
- const both = new Map([...cursorPlugin({ name: "same" }), ...claudePlugin({ name: "same" })]);
24
- expect(accepted(read(both))).toMatchObject({ dialect: "claude", manifestsFound: [".claude-plugin/plugin.json", ".cursor-plugin/plugin.json"] });
25
- const cursorAndCodex = new Map([...codexPlugin({ name: "same" }), ...cursorPlugin({ name: "same" })]);
26
- expect(accepted(read(cursorAndCodex)).dialect).toBe("cursor");
27
- expect(accepted(read(codexPlugin({ name: "same" }))).dialect).toBe("codex");
28
- });
29
-
30
- it("a root manifest beside a vendor manifest keeps the root identity and reads the vendor's declarations", () => {
31
- // With a root manifest present, `mcp.json` is the open format's fixed
32
- // location and is read under its rules; the Cursor manifest contributes
33
- // what the open format cannot say (the variables declaration).
34
- const files = new Map([
35
- ...openPlugin({ name: "dual", version: "2.0.0", mcpServers: { s: { type: "streamable-http", url: "https://example.com/mcp", headers: { Authorization: "Bearer ${TOKEN}" } } } }),
36
- ...cursorPlugin({ name: "dual", version: "9.9.9", variables: { TOKEN: { type: "string" } } }),
37
- ]);
38
- const plugin = accepted(read(files));
39
- expect(plugin.version).toBe("2.0.0");
40
- expect(plugin.dialect).toBe("agent-plugins");
41
- expect(plugin.variables.map((v) => v.name)).toEqual(["TOKEN"]);
42
- expect(plugin.mcpServers.map((s) => s.name)).toEqual(["s"]);
43
- });
44
-
45
- it("a Cursor-shaped mcp.json beside a root manifest is refused under the open format's rules", () => {
46
- const files = new Map([
47
- ...openPlugin({ name: "dual" }),
48
- ...cursorPlugin({ name: "dual", mcpServers: { s: { type: "http", url: "https://example.com/mcp" } } }),
49
- ]);
50
- expect(kindsOf(read(files)).errors).toEqual(["mcp-config-schema-missing", "mcp-server-type-unknown"]);
51
- });
52
-
53
- it("refuses manifests that disagree on the name", () => {
54
- const files = new Map([...openPlugin({ name: "one" }), ...cursorPlugin({ name: "two" })]);
55
- const finding = findingOf(refused(read(files)), "manifest-name-conflict");
56
- expect(finding.message).toBe("manifests disagree on the plugin name: 'one' in 'plugin.json' versus 'two' in '.cursor-plugin/plugin.json'");
57
- });
58
-
59
- it("refuses a directory with no manifest and names the four locations", () => {
60
- const finding = findingOf(refused(read(new Map([["README.md", "hi"]]))), "no-manifest");
61
- expect(finding.message).toContain("'plugin.json', '.claude-plugin/plugin.json', '.cursor-plugin/plugin.json', '.codex-plugin/plugin.json'");
62
- });
63
- });
64
-
65
- describe("the plugin name rule", () => {
66
- it("accepts the open format's valid names and refuses its invalid ones", () => {
67
- for (const name of ["my-plugin", "acme.tools", "lint3r", "a"]) expect(isValidPluginName(name), name).toBe(true);
68
- for (const name of ["My-Plugin", "-start", "has--double", "too.many..dots", "", "a".repeat(65), "end-"]) {
69
- expect(isValidPluginName(name), name).toBe(false);
70
- }
71
- });
72
-
73
- it("refuses an invalid name with the rule spelled out", () => {
74
- const finding = findingOf(refused(read(cursorPlugin({ name: "Bad Name" }))), "manifest-name-invalid");
75
- expect(finding.message).toContain("'Bad Name' in '.cursor-plugin/plugin.json' is invalid");
76
- });
77
-
78
- it("refuses a missing name once, naming the manifest", () => {
79
- const files = withManifestField(claudePlugin(), ".claude-plugin/plugin.json", "name", undefined);
80
- expect(kindsOf(read(files)).errors).toEqual(["manifest-name-missing"]);
81
- });
82
- });
83
-
84
- describe("the open manifest's schema", () => {
85
- it("refuses a missing and an unsupported $schema, each with its own sentence", () => {
86
- expect(kindsOf(read(openPlugin({ schema: null }))).errors).toEqual(["manifest-schema-missing"]);
87
- const unsupported = findingOf(refused(read(openPlugin({ schema: "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json" }))), "manifest-schema-unsupported");
88
- expect(unsupported.message).toContain("2.0.0");
89
- });
90
-
91
- it("reports an unreadable manifest once and does not also blame its missing name", () => {
92
- const files = new Map([["plugin.json", "{ not json"]]);
93
- expect(kindsOf(read(files)).errors).toEqual(["manifest-unreadable"]);
94
- });
95
-
96
- it("decodes a manifest saved with a byte-order mark", () => {
97
- const files = openPlugin();
98
- const text = files.get("plugin.json") as string;
99
- files.set("plugin.json", `\ufeff${text}`);
100
- expect(accepted(read(files)).name).toBe("example");
101
- });
102
- });
103
-
104
- describe("unknown and mistyped fields", () => {
105
- it("warns on an unknown top-level field in every dialect", () => {
106
- expect(kindsOf(read(openPlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
107
- expect(kindsOf(read(claudePlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
108
- expect(kindsOf(read(cursorPlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
109
- expect(kindsOf(read(codexPlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
110
- });
111
-
112
- it("does not warn on a dialect's own metadata fields", () => {
113
- const cursor = cursorPlugin({ manifest: { displayName: "X", publisher: "Acme", category: "c", tags: ["t"], author: { name: "a", email: "a@b.c" } } });
114
- expect(kindsOf(read(cursor))).toEqual({ errors: [], warnings: [] });
115
- const codex = codexPlugin({ manifest: { interface: { displayName: "X" } } });
116
- expect(kindsOf(read(codex))).toEqual({ errors: [], warnings: [] });
117
- });
118
-
119
- it("refuses a known field of the wrong type, naming the field", () => {
120
- const finding = findingOf(refused(read(openPlugin({ manifest: { keywords: "not-an-array" } }))), "manifest-field-type");
121
- expect(finding).toMatchObject({ path: "plugin.json", subject: "keywords" });
122
- });
123
-
124
- it("records a non-empty extension namespace as ignored and an empty ai.stigmer namespace as nothing", () => {
125
- const plugin = accepted(read(openPlugin({ manifest: { extensions: { "com.openai": { apps: "./.app.json" }, "ai.stigmer": {} } } })));
126
- expect(plugin.ignored).toEqual([{ kind: "extension", path: "plugin.json#extensions.com.openai" }]);
127
- });
128
-
129
- it("reads ai.stigmer's appearance fields the way Cursor's are read: the logo an ignored component, the rest silent", () => {
130
- const plugin = accepted(read(openPlugin({ manifest: { extensions: { "ai.stigmer": { displayName: "Linear", logo: "assets/logo.png", category: "integrations" } } } })));
131
- expect(plugin.ignored).toEqual([{ kind: "logo", path: "plugin.json#extensions.ai.stigmer.logo" }]);
132
- expect(kindsOf(read(openPlugin({ manifest: { extensions: { "ai.stigmer": { displayName: "Linear" } } } })))).toEqual({ errors: [], warnings: [] });
133
- });
134
-
135
- it("a field ai.stigmer does not define makes the namespace an ignored extension, so a misspelling stays visible", () => {
136
- const plugin = accepted(read(openPlugin({ manifest: { extensions: { "ai.stigmer": { displayname: "Linear" } } } })));
137
- expect(plugin.ignored).toEqual([{ kind: "extension", path: "plugin.json#extensions.ai.stigmer" }]);
138
- });
139
-
140
- it("warns on a non-object extensions field and keeps reading", () => {
141
- const outcome = read(openPlugin({ manifest: { extensions: "nope" } }));
142
- expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["manifest-extensions-invalid"] });
143
- });
144
- });