@stigmer/plugin-package 3.15.3-dev.20260916211208

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 (173) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +66 -0
  3. package/detect.d.ts +50 -0
  4. package/detect.d.ts.map +1 -0
  5. package/detect.js +164 -0
  6. package/detect.js.map +1 -0
  7. package/dialects/claude.d.ts +30 -0
  8. package/dialects/claude.d.ts.map +1 -0
  9. package/dialects/claude.js +71 -0
  10. package/dialects/claude.js.map +1 -0
  11. package/dialects/codex.d.ts +18 -0
  12. package/dialects/codex.d.ts.map +1 -0
  13. package/dialects/codex.js +19 -0
  14. package/dialects/codex.js.map +1 -0
  15. package/dialects/cursor.d.ts +23 -0
  16. package/dialects/cursor.d.ts.map +1 -0
  17. package/dialects/cursor.js +63 -0
  18. package/dialects/cursor.js.map +1 -0
  19. package/dialects/manifest.d.ts +109 -0
  20. package/dialects/manifest.d.ts.map +1 -0
  21. package/dialects/manifest.js +194 -0
  22. package/dialects/manifest.js.map +1 -0
  23. package/dialects/open.d.ts +18 -0
  24. package/dialects/open.d.ts.map +1 -0
  25. package/dialects/open.js +47 -0
  26. package/dialects/open.js.map +1 -0
  27. package/documents.d.ts +43 -0
  28. package/documents.d.ts.map +1 -0
  29. package/documents.js +111 -0
  30. package/documents.js.map +1 -0
  31. package/files.d.ts +114 -0
  32. package/files.d.ts.map +1 -0
  33. package/files.js +187 -0
  34. package/files.js.map +1 -0
  35. package/frontmatter.d.ts +51 -0
  36. package/frontmatter.d.ts.map +1 -0
  37. package/frontmatter.js +61 -0
  38. package/frontmatter.js.map +1 -0
  39. package/index.d.ts +19 -0
  40. package/index.d.ts.map +1 -0
  41. package/index.js +17 -0
  42. package/index.js.map +1 -0
  43. package/messages.d.ts +39 -0
  44. package/messages.d.ts.map +1 -0
  45. package/messages.js +135 -0
  46. package/messages.js.map +1 -0
  47. package/normalise/ignored.d.ts +24 -0
  48. package/normalise/ignored.d.ts.map +1 -0
  49. package/normalise/ignored.js +68 -0
  50. package/normalise/ignored.js.map +1 -0
  51. package/normalise/mcp-servers.d.ts +47 -0
  52. package/normalise/mcp-servers.d.ts.map +1 -0
  53. package/normalise/mcp-servers.js +397 -0
  54. package/normalise/mcp-servers.js.map +1 -0
  55. package/normalise/overlay.d.ts +27 -0
  56. package/normalise/overlay.d.ts.map +1 -0
  57. package/normalise/overlay.js +67 -0
  58. package/normalise/overlay.js.map +1 -0
  59. package/normalise/skills.d.ts +31 -0
  60. package/normalise/skills.d.ts.map +1 -0
  61. package/normalise/skills.js +116 -0
  62. package/normalise/skills.js.map +1 -0
  63. package/normalise/sub-agents.d.ts +44 -0
  64. package/normalise/sub-agents.d.ts.map +1 -0
  65. package/normalise/sub-agents.js +168 -0
  66. package/normalise/sub-agents.js.map +1 -0
  67. package/normalise/variables.d.ts +27 -0
  68. package/normalise/variables.d.ts.map +1 -0
  69. package/normalise/variables.js +155 -0
  70. package/normalise/variables.js.map +1 -0
  71. package/outcome.d.ts +46 -0
  72. package/outcome.d.ts.map +1 -0
  73. package/outcome.js +21 -0
  74. package/outcome.js.map +1 -0
  75. package/package.json +40 -0
  76. package/placeholders.d.ts +41 -0
  77. package/placeholders.d.ts.map +1 -0
  78. package/placeholders.js +68 -0
  79. package/placeholders.js.map +1 -0
  80. package/read-plugin-package.d.ts +19 -0
  81. package/read-plugin-package.d.ts.map +1 -0
  82. package/read-plugin-package.js +67 -0
  83. package/read-plugin-package.js.map +1 -0
  84. package/src/__test-utils__/directory-files.ts +29 -0
  85. package/src/__test-utils__/read.ts +55 -0
  86. package/src/__tests__/adversarial.test.ts +434 -0
  87. package/src/__tests__/detect.test.ts +133 -0
  88. package/src/__tests__/files.test.ts +119 -0
  89. package/src/__tests__/fixtures/cursor-plugins/NOTICE +22 -0
  90. package/src/__tests__/fixtures/cursor-plugins/advisor/.cursor-plugin/plugin.json +33 -0
  91. package/src/__tests__/fixtures/cursor-plugins/advisor/CHANGELOG.md +8 -0
  92. package/src/__tests__/fixtures/cursor-plugins/advisor/LICENSE +21 -0
  93. package/src/__tests__/fixtures/cursor-plugins/advisor/README.md +87 -0
  94. package/src/__tests__/fixtures/cursor-plugins/advisor/agents/advisor-subagent.md +48 -0
  95. package/src/__tests__/fixtures/cursor-plugins/advisor/assets/avatar.png +0 -0
  96. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/capture-response.sh +20 -0
  97. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/hooks.json +27 -0
  98. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/lib.sh +61 -0
  99. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/mark-pending.sh +27 -0
  100. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/record-consult.sh +41 -0
  101. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/stop-hook.sh +48 -0
  102. package/src/__tests__/fixtures/cursor-plugins/advisor/skills/advisor/SKILL.md +123 -0
  103. package/src/__tests__/fixtures/cursor-plugins/advisor/skills/advisor/references/briefing-template.md +44 -0
  104. package/src/__tests__/fixtures/cursor-plugins/github/.cursor-plugin/plugin.json +45 -0
  105. package/src/__tests__/fixtures/cursor-plugins/github/CHANGELOG.md +9 -0
  106. package/src/__tests__/fixtures/cursor-plugins/github/LICENSE +21 -0
  107. package/src/__tests__/fixtures/cursor-plugins/github/README.md +64 -0
  108. package/src/__tests__/fixtures/cursor-plugins/github/assets/logo.svg +0 -0
  109. package/src/__tests__/fixtures/cursor-plugins/github/mcp.json +11 -0
  110. package/src/__tests__/fixtures/cursor-plugins/playwright/.cursor-plugin/plugin.json +35 -0
  111. package/src/__tests__/fixtures/cursor-plugins/playwright/CHANGELOG.md +8 -0
  112. package/src/__tests__/fixtures/cursor-plugins/playwright/LICENSE +21 -0
  113. package/src/__tests__/fixtures/cursor-plugins/playwright/README.md +46 -0
  114. package/src/__tests__/fixtures/cursor-plugins/playwright/assets/logo.svg +0 -0
  115. package/src/__tests__/fixtures/cursor-plugins/playwright/mcp.json +8 -0
  116. package/src/__tests__/fixtures/cursor-plugins/salesforce/.cursor-plugin/plugin.json +48 -0
  117. package/src/__tests__/fixtures/cursor-plugins/salesforce/CHANGELOG.md +10 -0
  118. package/src/__tests__/fixtures/cursor-plugins/salesforce/LICENSE +21 -0
  119. package/src/__tests__/fixtures/cursor-plugins/salesforce/README.md +95 -0
  120. package/src/__tests__/fixtures/cursor-plugins/salesforce/assets/logo.svg +0 -0
  121. package/src/__tests__/fixtures/cursor-plugins/salesforce/mcp.json +12 -0
  122. package/src/__tests__/fixtures/cursor-plugins/thermos/.cursor-plugin/plugin.json +32 -0
  123. package/src/__tests__/fixtures/cursor-plugins/thermos/CHANGELOG.md +8 -0
  124. package/src/__tests__/fixtures/cursor-plugins/thermos/LICENSE +21 -0
  125. package/src/__tests__/fixtures/cursor-plugins/thermos/README.md +70 -0
  126. package/src/__tests__/fixtures/cursor-plugins/thermos/agents/thermo-nuclear-code-quality-review-subagent.md +23 -0
  127. package/src/__tests__/fixtures/cursor-plugins/thermos/agents/thermo-nuclear-review-subagent.md +28 -0
  128. package/src/__tests__/fixtures/cursor-plugins/thermos/assets/logo.png +0 -0
  129. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
  130. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermo-nuclear-review/SKILL.md +51 -0
  131. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermos/SKILL.md +21 -0
  132. package/src/__tests__/fixtures/cursor-plugins/xero/.cursor-plugin/plugin.json +53 -0
  133. package/src/__tests__/fixtures/cursor-plugins/xero/CHANGELOG.md +9 -0
  134. package/src/__tests__/fixtures/cursor-plugins/xero/LICENSE +21 -0
  135. package/src/__tests__/fixtures/cursor-plugins/xero/README.md +79 -0
  136. package/src/__tests__/fixtures/cursor-plugins/xero/assets/logo.png +0 -0
  137. package/src/__tests__/fixtures/cursor-plugins/xero/mcp.json +16 -0
  138. package/src/__tests__/fixtures.test.ts +198 -0
  139. package/src/__tests__/mcp-servers.test.ts +120 -0
  140. package/src/__tests__/overlay-and-ignored.test.ts +65 -0
  141. package/src/__tests__/skills.test.ts +89 -0
  142. package/src/__tests__/sub-agents.test.ts +111 -0
  143. package/src/__tests__/variables.test.ts +94 -0
  144. package/src/detect.ts +189 -0
  145. package/src/dialects/claude.ts +90 -0
  146. package/src/dialects/codex.ts +24 -0
  147. package/src/dialects/cursor.ts +73 -0
  148. package/src/dialects/manifest.ts +237 -0
  149. package/src/dialects/open.ts +48 -0
  150. package/src/documents.ts +145 -0
  151. package/src/files.ts +213 -0
  152. package/src/frontmatter.ts +70 -0
  153. package/src/index.ts +59 -0
  154. package/src/messages.ts +206 -0
  155. package/src/normalise/ignored.ts +70 -0
  156. package/src/normalise/mcp-servers.ts +427 -0
  157. package/src/normalise/overlay.ts +71 -0
  158. package/src/normalise/skills.ts +126 -0
  159. package/src/normalise/sub-agents.ts +184 -0
  160. package/src/normalise/variables.ts +161 -0
  161. package/src/outcome.ts +122 -0
  162. package/src/placeholders.ts +74 -0
  163. package/src/read-plugin-package.ts +74 -0
  164. package/src/testing.ts +258 -0
  165. package/src/types.ts +189 -0
  166. package/testing.d.ts +106 -0
  167. package/testing.d.ts.map +1 -0
  168. package/testing.js +182 -0
  169. package/testing.js.map +1 -0
  170. package/types.d.ts +152 -0
  171. package/types.d.ts.map +1 -0
  172. package/types.js +19 -0
  173. package/types.js.map +1 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The fixture suite: six real Cursor plugins, vendored from cursor/plugins
3
+ * at c1c0a32 (see fixtures/cursor-plugins/NOTICE), read exactly as a user's
4
+ * checkout would be. Every assertion names the field it pins; there are no
5
+ * snapshot files, so a change in what the reader produces is a change
6
+ * someone wrote down here.
7
+ *
8
+ * Between them the six cover: Cursor `variables` with `required` (github,
9
+ * xero, salesforce), a `${VAR}` inside a header (github), stdio `env` as
10
+ * `KEY: "${KEY}"` (xero), an entry with no `type` inferred to stdio
11
+ * (playwright), a `${VAR}` url plus the undocumented `auth` block
12
+ * (salesforce, the one refusal), two sub-agents and no server (thermos), a
13
+ * sub-agent with an unmappable model and a `readonly` field plus hooks that
14
+ * reference `${CURSOR_PLUGIN_ROOT}` and must never be read (advisor), skill
15
+ * frontmatter with vendor keys, `minClientVersions`, a `references/` file
16
+ * inside a skill, and image assets that are listed and never opened.
17
+ */
18
+
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ import { describe, expect, it } from "vitest";
22
+
23
+ import { readPluginPackage } from "../read-plugin-package.js";
24
+ import { directoryPluginFiles } from "../__test-utils__/directory-files.js";
25
+ import { accepted, findingOf, kindsOf, refused } from "../__test-utils__/read.js";
26
+
27
+ const FIXTURES = fileURLToPath(new URL("./fixtures/cursor-plugins/", import.meta.url));
28
+
29
+ function readFixture(name: string) {
30
+ return readPluginPackage(directoryPluginFiles(`${FIXTURES}${name}`));
31
+ }
32
+
33
+ describe("thermos: skills and sub-agents, no server", () => {
34
+ const outcome = readFixture("thermos");
35
+
36
+ it("is accepted with no warnings", () => {
37
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
38
+ });
39
+
40
+ it("carries the manifest identity", () => {
41
+ const plugin = accepted(outcome);
42
+ expect(plugin).toMatchObject({
43
+ name: "thermos",
44
+ version: "1.0.0",
45
+ license: "MIT",
46
+ dialect: "cursor",
47
+ manifestsFound: [".cursor-plugin/plugin.json"],
48
+ author: { name: "Cursor", email: "plugins@cursor.com" },
49
+ homepage: "https://github.com/cursor/plugins",
50
+ });
51
+ expect(plugin.keywords).toContain("thermo-nuclear");
52
+ });
53
+
54
+ it("reads the three skills with their descriptions and files", () => {
55
+ const plugin = accepted(outcome);
56
+ expect(plugin.skills.map((s) => s.name)).toEqual(["thermo-nuclear-code-quality-review", "thermo-nuclear-review", "thermos"]);
57
+ expect(plugin.skills[2]?.description).toMatch(/^Launch both thermo-nuclear review subagents/);
58
+ expect(plugin.skills[2]?.files).toEqual(["skills/thermos/SKILL.md"]);
59
+ });
60
+
61
+ it("reads the two sub-agents with the file body as instructions and no model hint", () => {
62
+ const plugin = accepted(outcome);
63
+ expect(plugin.subAgents.map((a) => a.name)).toEqual(["thermo-nuclear-code-quality-review-subagent", "thermo-nuclear-review-subagent"]);
64
+ const review = plugin.subAgents[1];
65
+ expect(review?.description).toMatch(/^Thermo-nuclear branch audit/);
66
+ expect(review?.instructions.length).toBeGreaterThan(1000);
67
+ expect(review?.modelHint).toBeUndefined();
68
+ expect(review?.path).toBe("agents/thermo-nuclear-review-subagent.md");
69
+ });
70
+
71
+ it("has no servers and no variables, and records only the logo", () => {
72
+ const plugin = accepted(outcome);
73
+ expect(plugin.mcpServers).toEqual([]);
74
+ expect(plugin.variables).toEqual([]);
75
+ expect(plugin.ignored).toEqual([
76
+ { kind: "assets", path: "assets/" },
77
+ { kind: "logo", path: ".cursor-plugin/plugin.json#logo" },
78
+ ]);
79
+ });
80
+ });
81
+
82
+ describe("github: an HTTP server with a token in a header", () => {
83
+ const outcome = readFixture("github");
84
+
85
+ it("is accepted with no warnings", () => {
86
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
87
+ });
88
+
89
+ it("reads the server with the header reference on its env", () => {
90
+ expect(accepted(outcome).mcpServers).toEqual([
91
+ {
92
+ name: "github",
93
+ transport: "http",
94
+ url: "https://api.githubcopilot.com/mcp/",
95
+ headers: { Authorization: "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" },
96
+ env: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
97
+ },
98
+ ]);
99
+ });
100
+
101
+ it("declares the required variable as a secret with the joined title and description", () => {
102
+ expect(accepted(outcome).variables).toEqual([
103
+ {
104
+ name: "GITHUB_PERSONAL_ACCESS_TOKEN",
105
+ description:
106
+ "GitHub personal access token: Fine-grained or classic PAT from https://github.com/settings/tokens with the repo scopes you want the agent to use.",
107
+ isSecret: true,
108
+ optional: false,
109
+ declaredBy: "cursor",
110
+ },
111
+ ]);
112
+ });
113
+
114
+ it("records minClientVersions and the logo as ignored", () => {
115
+ expect(accepted(outcome).ignored.map((c) => c.kind).sort()).toEqual(["assets", "logo", "min-client-versions"]);
116
+ });
117
+ });
118
+
119
+ describe("xero: a stdio server passing two secrets by name", () => {
120
+ const outcome = readFixture("xero");
121
+
122
+ it("is accepted with no warnings", () => {
123
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
124
+ });
125
+
126
+ it("reads the npx command with its args and both env references", () => {
127
+ expect(accepted(outcome).mcpServers).toEqual([
128
+ {
129
+ name: "xero",
130
+ transport: "stdio",
131
+ command: "npx",
132
+ args: ["-y", "@xeroapi/xero-mcp-server@latest"],
133
+ env: ["XERO_CLIENT_ID", "XERO_CLIENT_SECRET"],
134
+ },
135
+ ]);
136
+ });
137
+
138
+ it("declares both variables as required secrets", () => {
139
+ expect(accepted(outcome).variables.map((v) => [v.name, v.isSecret, v.optional])).toEqual([
140
+ ["XERO_CLIENT_ID", true, false],
141
+ ["XERO_CLIENT_SECRET", true, false],
142
+ ]);
143
+ });
144
+ });
145
+
146
+ describe("playwright: a server with no type and no variables", () => {
147
+ const outcome = readFixture("playwright");
148
+
149
+ it("is accepted with no warnings and infers stdio from command", () => {
150
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
151
+ expect(accepted(outcome).mcpServers).toEqual([
152
+ { name: "playwright", transport: "stdio", command: "npx", args: ["-y", "@playwright/mcp@latest"], env: [] },
153
+ ]);
154
+ expect(accepted(outcome).variables).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("salesforce: a variable in the url is refused", () => {
159
+ const outcome = readFixture("salesforce");
160
+
161
+ it("is refused for the url alone, with the auth block warned", () => {
162
+ expect(kindsOf(outcome)).toEqual({ errors: ["mcp-server-url-variable"], warnings: ["mcp-server-auth-ignored"] });
163
+ expect(findingOf(refused(outcome), "mcp-server-url-variable").message).toBe(
164
+ "MCP server 'salesforce' in 'mcp.json' has a variable in its 'url'; Stigmer sends the URL as written, so write the URL out and put variables in 'headers'",
165
+ );
166
+ });
167
+ });
168
+
169
+ describe("advisor: a sub-agent with an unmappable model, and hooks that are never read", () => {
170
+ const outcome = readFixture("advisor");
171
+
172
+ it("is accepted with the model and the readonly field warned", () => {
173
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["sub-agent-field-ignored", "sub-agent-model-unknown"] });
174
+ expect(findingOf(outcome.warnings, "sub-agent-model-unknown")).toMatchObject({ subject: "advisor-subagent", detail: "grok-4.6[effort=xhigh]" });
175
+ expect(findingOf(outcome.warnings, "sub-agent-field-ignored")).toMatchObject({ subject: "advisor-subagent", detail: "readonly" });
176
+ });
177
+
178
+ it("keeps the raw model text in the hint", () => {
179
+ expect(accepted(outcome).subAgents[0]?.modelHint).toEqual({ raw: "grok-4.6[effort=xhigh]", alias: "unknown" });
180
+ });
181
+
182
+ it("reads the skill with its references file and vendor frontmatter keys", () => {
183
+ const skill = accepted(outcome).skills[0];
184
+ expect(skill?.name).toBe("advisor");
185
+ expect(skill?.files).toEqual(["skills/advisor/SKILL.md", "skills/advisor/references/briefing-template.md"]);
186
+ });
187
+
188
+ it("records hooks as ignored without reading the ${CURSOR_PLUGIN_ROOT} inside them", () => {
189
+ const plugin = accepted(outcome);
190
+ expect(plugin.ignored).toEqual([
191
+ { kind: "assets", path: "assets/" },
192
+ { kind: "hooks", path: "hooks/" },
193
+ { kind: "logo", path: ".cursor-plugin/plugin.json#logo" },
194
+ { kind: "hooks", path: ".cursor-plugin/plugin.json#hooks" },
195
+ ]);
196
+ expect(plugin.mcpServers).toEqual([]);
197
+ });
198
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Pins the MCP server normalisation on its accepting paths: the two
3
+ * transports in both the open and the vendor dialects, transport inference,
4
+ * `sse` mapped with its warning, the `${user_config.KEY}` rewrite, header
5
+ * and argument references collected onto `env`, the Cursor `auth` block
6
+ * warned and scanned, inline and multiple sources, and the URL rule's
7
+ * loopback exception. Refusals are pinned kind by kind in the adversarial
8
+ * suite.
9
+ */
10
+
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { claudePlugin, cursorPlugin, openPlugin } from "../testing.js";
14
+ import { accepted, findingOf, kindsOf, read } from "../__test-utils__/read.js";
15
+
16
+ describe("open dialect", () => {
17
+ it("reads a stdio and a streamable-http server into the two spec shapes", () => {
18
+ const plugin = accepted(
19
+ read(
20
+ openPlugin({
21
+ mcpServers: {
22
+ files: { type: "stdio", command: "npx", args: ["-y", "@acme/files", "--token", "${TOKEN}"], env: { TOKEN: "${TOKEN}" } },
23
+ api: { type: "streamable-http", url: "https://api.example.com/mcp", headers: { Authorization: "Bearer ${API_KEY}", "X-Org": "acme" } },
24
+ },
25
+ }),
26
+ ),
27
+ );
28
+ expect(plugin.mcpServers).toEqual([
29
+ { name: "files", transport: "stdio", command: "npx", args: ["-y", "@acme/files", "--token", "${TOKEN}"], env: ["TOKEN"] },
30
+ { name: "api", transport: "http", url: "https://api.example.com/mcp", headers: { Authorization: "Bearer ${API_KEY}", "X-Org": "acme" }, env: ["API_KEY"] },
31
+ ]);
32
+ });
33
+
34
+ it("maps the legacy sse transport to http with the fallback warning", () => {
35
+ const outcome = read(openPlugin({ mcpServers: { legacy: { type: "sse", url: "https://legacy.example.com/sse" } } }));
36
+ expect(kindsOf(outcome).warnings).toEqual(["mcp-server-sse-mapped"]);
37
+ expect(accepted(outcome).mcpServers[0]).toMatchObject({ transport: "http", url: "https://legacy.example.com/sse" });
38
+ expect(findingOf(outcome.warnings, "mcp-server-sse-mapped").message).toContain("falls back to SSE only when the server rejects it");
39
+ });
40
+
41
+ it("allows plain http for loopback hosts only", () => {
42
+ const outcome = read(openPlugin({ mcpServers: { local: { type: "streamable-http", url: "http://localhost:8080/mcp" }, ip: { type: "streamable-http", url: "http://127.0.0.1/mcp" } } }));
43
+ expect(accepted(outcome).mcpServers).toHaveLength(2);
44
+ });
45
+ });
46
+
47
+ describe("vendor dialects", () => {
48
+ it("infers the transport from command or url when type is absent", () => {
49
+ const plugin = accepted(
50
+ read(
51
+ cursorPlugin({
52
+ mcpServers: {
53
+ play: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
54
+ web: { url: "https://mcp.example.com/" },
55
+ explicit: { type: "http", url: "https://mcp.example.com/two" },
56
+ },
57
+ }),
58
+ ),
59
+ );
60
+ expect(plugin.mcpServers.map((s) => [s.name, s.transport])).toEqual([
61
+ ["play", "stdio"],
62
+ ["web", "http"],
63
+ ["explicit", "http"],
64
+ ]);
65
+ });
66
+
67
+ it("rewrites Claude's ${user_config.KEY} to ${KEY} in headers, args and env", () => {
68
+ const plugin = accepted(
69
+ read(
70
+ claudePlugin({
71
+ userConfig: { API_KEY: { type: "string", sensitive: true, required: true }, DB: { type: "string" } },
72
+ mcpServers: {
73
+ api: { url: "https://api.example.com/mcp", headers: { Authorization: "Bearer ${user_config.API_KEY}" } },
74
+ db: { command: "uvx", args: ["db-mcp", "--name", "${user_config.DB}"], env: { DB: "${user_config.DB}" } },
75
+ },
76
+ }),
77
+ ),
78
+ );
79
+ expect(plugin.mcpServers[0]).toMatchObject({ headers: { Authorization: "Bearer ${API_KEY}" }, env: ["API_KEY"] });
80
+ expect(plugin.mcpServers[1]).toMatchObject({ args: ["db-mcp", "--name", "${DB}"], env: ["DB"] });
81
+ });
82
+
83
+ it("warns on Cursor's auth block and still counts the variables inside it as references", () => {
84
+ const outcome = read(
85
+ cursorPlugin({
86
+ variables: { CLIENT_ID: { type: "string" } },
87
+ required: ["CLIENT_ID"],
88
+ mcpServers: { crm: { type: "http", url: "https://crm.example.com/mcp", auth: { CLIENT_ID: "${CLIENT_ID}", scopes: ["a"] } } },
89
+ }),
90
+ );
91
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["mcp-server-auth-ignored"] });
92
+ expect(accepted(outcome).mcpServers[0]?.env).toEqual(["CLIENT_ID"]);
93
+ expect(findingOf(outcome.warnings, "mcp-server-auth-ignored").message).toContain("ai.stigmer/mcp-servers/crm.yaml");
94
+ });
95
+
96
+ it("warns on a field Stigmer does not read instead of refusing", () => {
97
+ const outcome = read(cursorPlugin({ mcpServers: { s: { type: "http", url: "https://x.example.com/mcp", timeout: 30 } } }));
98
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["mcp-server-field-ignored"] });
99
+ });
100
+
101
+ it("reads an inline mcpServers object and a declared file together", () => {
102
+ const files = cursorPlugin({ mcpServers: { fromFile: { type: "http", url: "https://a.example.com/mcp" } } });
103
+ const manifest = JSON.parse(files.get(".cursor-plugin/plugin.json") as string) as Record<string, unknown>;
104
+ manifest["mcpServers"] = ["./mcp.json", { mcpServers: { inline: { type: "http", url: "https://b.example.com/mcp" } } }];
105
+ files.set(".cursor-plugin/plugin.json", JSON.stringify(manifest));
106
+ const plugin = accepted(read(files));
107
+ expect(plugin.mcpServers.map((s) => s.name)).toEqual(["fromFile", "inline"]);
108
+ });
109
+
110
+ it("reads Claude's default .mcp.json when the manifest declares no mcpServers", () => {
111
+ const plugin = accepted(read(claudePlugin({ mcpServers: { s: { command: "npx", args: ["x"] } } })));
112
+ expect(plugin.mcpServers.map((s) => s.name)).toEqual(["s"]);
113
+ });
114
+
115
+ it("warns on a top-level .mcp.json field it does not read, where the open format would refuse", () => {
116
+ const files = claudePlugin({ mcpServers: { s: { command: "npx" } } });
117
+ files.set(".mcp.json", JSON.stringify({ mcpServers: { s: { command: "npx" } }, comment: "x" }));
118
+ expect(kindsOf(read(files))).toEqual({ errors: [], warnings: ["mcp-config-field-ignored"] });
119
+ });
120
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pins the `ai.stigmer/` overlay (the three document shapes located and
3
+ * handed over as bytes with the server check) and the ignored-component
4
+ * record (directories and files on disk, manifest fields, deduplicated,
5
+ * never per file, with hook scripts never read).
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import { cursorPlugin, openPlugin } from "../testing.js";
11
+ import { accepted, kindsOf, read } from "../__test-utils__/read.js";
12
+
13
+ describe("the ai.stigmer/ overlay", () => {
14
+ it("locates the agent, workflow and server documents and hands them over as bytes", () => {
15
+ const files = openPlugin({
16
+ mcpServers: { gh: { type: "streamable-http", url: "https://gh.example.com/mcp" } },
17
+ files: {
18
+ "ai.stigmer/agent.yaml": "kind: Agent\n",
19
+ "ai.stigmer/workflows/triage.yaml": "kind: Workflow\n",
20
+ "ai.stigmer/mcp-servers/gh.yaml": "spec:\n auth: {}\n",
21
+ },
22
+ });
23
+ const overlay = accepted(read(files)).overlay;
24
+ expect(overlay.agent?.path).toBe("ai.stigmer/agent.yaml");
25
+ expect(new TextDecoder().decode(overlay.agent?.bytes)).toBe("kind: Agent\n");
26
+ expect(overlay.workflows.map((w) => [w.name, w.path])).toEqual([["triage", "ai.stigmer/workflows/triage.yaml"]]);
27
+ expect(overlay.mcpServers.map((s) => [s.server, s.path])).toEqual([["gh", "ai.stigmer/mcp-servers/gh.yaml"]]);
28
+ });
29
+
30
+ it("is empty when the folder is absent", () => {
31
+ expect(accepted(read(openPlugin())).overlay).toEqual({ workflows: [], mcpServers: [] });
32
+ });
33
+ });
34
+
35
+ describe("ignored components", () => {
36
+ it("records each component directory and file once, and never reads inside them", () => {
37
+ const files = cursorPlugin({
38
+ manifest: { hooks: "./hooks/hooks.json", rules: "./rules/", logo: "assets/logo.png", minClientVersions: { cursor: "3.13.0" } },
39
+ files: {
40
+ "hooks/hooks.json": '{"hooks":{"stop":[{"command":"bash \\"${CURSOR_PLUGIN_ROOT}/hooks/stop.sh\\""}]}}',
41
+ "hooks/stop.sh": "exit 0",
42
+ "rules/one.mdc": "rule",
43
+ "rules/two.mdc": "rule",
44
+ "assets/logo.png": new Uint8Array([0x89, 0x50]),
45
+ ".lsp.json": "{}",
46
+ "settings.json": "{}",
47
+ "README.md": "readme",
48
+ "LICENSE": "MIT",
49
+ },
50
+ });
51
+ const outcome = read(files);
52
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
53
+ expect(accepted(outcome).ignored).toEqual([
54
+ { kind: "assets", path: "assets/" },
55
+ { kind: "hooks", path: "hooks/" },
56
+ { kind: "rules", path: "rules/" },
57
+ { kind: "lsp-servers", path: ".lsp.json" },
58
+ { kind: "settings", path: "settings.json" },
59
+ { kind: "hooks", path: ".cursor-plugin/plugin.json#hooks" },
60
+ { kind: "rules", path: ".cursor-plugin/plugin.json#rules" },
61
+ { kind: "logo", path: ".cursor-plugin/plugin.json#logo" },
62
+ { kind: "min-client-versions", path: ".cursor-plugin/plugin.json#minClientVersions" },
63
+ ]);
64
+ });
65
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pins skill discovery across the dialects (the fixed `skills/` children,
3
+ * declared paths that are a skill or a directory of skills, Claude's root
4
+ * single skill) and the frontmatter check's relaxations (name defaulted to
5
+ * the directory, name differing from the directory, description missing),
6
+ * plus the per-skill file listing the installer builds archives from.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { claudePlugin, cursorPlugin, openPlugin, withFile } from "../testing.js";
12
+ import { accepted, kindsOf, read } from "../__test-utils__/read.js";
13
+
14
+ describe("discovery", () => {
15
+ it("reads the immediate children of skills/ that hold SKILL.md, and lists each skill's files", () => {
16
+ const files = openPlugin({
17
+ skills: [
18
+ { name: "alpha", description: "A", files: { "scripts/run.sh": "echo", "references/REF.md": "ref" } },
19
+ { name: "beta", description: "B" },
20
+ ],
21
+ });
22
+ files.set("skills/not-a-skill/README.md", "no SKILL.md here");
23
+ files.set("skills/alpha/nested/deeper/SKILL.md", "---\nname: deeper\ndescription: d\n---\nbody");
24
+ const plugin = accepted(read(files));
25
+ expect(plugin.skills.map((s) => s.name)).toEqual(["alpha", "beta"]);
26
+ expect(plugin.skills[0]).toMatchObject({
27
+ dir: "skills/alpha",
28
+ files: ["skills/alpha/SKILL.md", "skills/alpha/nested/deeper/SKILL.md", "skills/alpha/references/REF.md", "skills/alpha/scripts/run.sh"],
29
+ });
30
+ });
31
+
32
+ it("reads a declared directory of skills and a declared path that is itself a skill", () => {
33
+ const files = claudePlugin({ manifest: { skills: ["./extra/", "./solo"] } });
34
+ files.set("extra/one/SKILL.md", "---\nname: one\ndescription: d\n---\nbody");
35
+ files.set("solo/SKILL.md", "---\nname: solo\ndescription: d\n---\nbody");
36
+ files.set("skills/base/SKILL.md", "---\nname: base\ndescription: d\n---\nbody");
37
+ const plugin = accepted(read(files));
38
+ expect(plugin.skills.map((s) => s.dir)).toEqual(["extra/one", "skills/base", "solo"]);
39
+ });
40
+
41
+ it("reads a root SKILL.md as a single skill when nothing else declares skills", () => {
42
+ const files = claudePlugin({ name: "solo-skill" });
43
+ files.set("SKILL.md", "---\nname: solo-skill\ndescription: d\n---\nbody");
44
+ files.set("README.md", "readme");
45
+ const plugin = accepted(read(files));
46
+ expect(plugin.skills).toHaveLength(1);
47
+ expect(plugin.skills[0]).toMatchObject({ name: "solo-skill", dir: "" });
48
+ expect(plugin.skills[0]?.files).toContain("README.md");
49
+ });
50
+
51
+ it("does not read a root SKILL.md when skills/ exists", () => {
52
+ const files = openPlugin({ skills: [{ name: "a", description: "d" }] });
53
+ files.set("SKILL.md", "---\nname: root\ndescription: d\n---\nbody");
54
+ expect(accepted(read(files)).skills.map((s) => s.name)).toEqual(["a"]);
55
+ });
56
+
57
+ it("warns on a declared skills path that names nothing", () => {
58
+ const outcome = read(cursorPlugin({ manifest: { skills: "./missing/" } }));
59
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["path-missing"] });
60
+ });
61
+ });
62
+
63
+ describe("frontmatter", () => {
64
+ it("accepts optional and vendor frontmatter keys silently", () => {
65
+ const files = openPlugin({
66
+ skills: [{ name: "a", description: "d", frontmatter: { license: "MIT", metadata: { author: "x" }, "allowed-tools": "Bash", "disable-model-invocation": true, icon: "bulb" } }],
67
+ });
68
+ expect(kindsOf(read(files))).toEqual({ errors: [], warnings: [] });
69
+ });
70
+
71
+ it("names a skill after its directory when the frontmatter has no name, with a warning", () => {
72
+ const files = withFile(openPlugin(), "skills/from-dir/SKILL.md", "---\ndescription: d\n---\nbody");
73
+ const outcome = read(files);
74
+ expect(kindsOf(outcome).warnings).toEqual(["skill-name-defaulted"]);
75
+ expect(accepted(outcome).skills[0]?.name).toBe("from-dir");
76
+ });
77
+
78
+ it("warns when the name differs from the directory and when the description is missing", () => {
79
+ const files = openPlugin({ skills: [{ name: "real-name", dir: "other-dir" }] });
80
+ const outcome = read(files);
81
+ expect(kindsOf(outcome).warnings).toEqual(["skill-description-missing", "skill-name-differs-from-directory"]);
82
+ expect(accepted(outcome).skills[0]).toMatchObject({ name: "real-name", dir: "skills/other-dir" });
83
+ });
84
+
85
+ it("accepts Stigmer's dot-scoped names, a superset of the Agent Skills rule", () => {
86
+ const plugin = accepted(read(openPlugin({ skills: [{ name: "platform.my-skill", description: "d" }] })));
87
+ expect(plugin.skills[0]?.name).toBe("platform.my-skill");
88
+ });
89
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pins sub-agent normalisation: `agents/*.md` read under vendor manifests
3
+ * and recorded as ignored under a root-only manifest; the body as
4
+ * instructions; name from frontmatter or file stem; Claude `skills:`
5
+ * matched to the plugin's skills; `model` classified into an alias (never
6
+ * an id); every other frontmatter field warned once; declared agent paths
7
+ * that are files or directories.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { classifyModel } from "../normalise/sub-agents.js";
13
+ import { claudePlugin, cursorPlugin, openPlugin } from "../testing.js";
14
+ import { accepted, kindsOf, read } from "../__test-utils__/read.js";
15
+
16
+ describe("discovery", () => {
17
+ it("reads agents/*.md under a Cursor manifest into SubAgent shapes", () => {
18
+ const plugin = accepted(
19
+ read(
20
+ cursorPlugin({
21
+ agents: [{ file: "reviewer", frontmatter: { name: "code-reviewer", description: "Reviews code." }, body: "Review the diff carefully and report findings.\n" }],
22
+ }),
23
+ ),
24
+ );
25
+ expect(plugin.subAgents).toEqual([
26
+ {
27
+ name: "code-reviewer",
28
+ description: "Reviews code.",
29
+ instructions: "Review the diff carefully and report findings.",
30
+ skillNames: [],
31
+ path: "agents/reviewer.md",
32
+ },
33
+ ]);
34
+ });
35
+
36
+ it("records agents/ as ignored, unread, under a root-only manifest", () => {
37
+ const files = openPlugin();
38
+ files.set("agents/x.md", "---\nname: x\n---\nthis frontmatter would be fine");
39
+ const plugin = accepted(read(files));
40
+ expect(plugin.subAgents).toEqual([]);
41
+ expect(plugin.ignored).toEqual([{ kind: "agents", path: "agents/" }]);
42
+ });
43
+
44
+ it("reads declared agent files and directories, replacing the default directory", () => {
45
+ const files = claudePlugin({ manifest: { agents: ["./custom/one.md", "./more/"] } });
46
+ files.set("custom/one.md", "---\nname: one\n---\nDo the first thing well.");
47
+ files.set("more/two.md", "---\nname: two\n---\nDo the second thing well.");
48
+ files.set("agents/ignored.md", "---\nname: three\n---\nNot read: the manifest replaced agents/.");
49
+ const plugin = accepted(read(files));
50
+ expect(plugin.subAgents.map((a) => a.name)).toEqual(["one", "two"]);
51
+ });
52
+
53
+ it("names a bare prompt after its file with a warning", () => {
54
+ const outcome = read(claudePlugin({ agents: [{ file: "helper", frontmatter: null, body: "You help with everything, patiently." }] }));
55
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["sub-agent-name-defaulted"] });
56
+ expect(accepted(outcome).subAgents[0]).toMatchObject({ name: "helper", instructions: "You help with everything, patiently." });
57
+ });
58
+ });
59
+
60
+ describe("frontmatter fields", () => {
61
+ it("matches Claude skills: to the plugin's skills and warns on the rest", () => {
62
+ const outcome = read(
63
+ claudePlugin({
64
+ skills: [{ name: "known", description: "d" }],
65
+ agents: [{ file: "a", frontmatter: { skills: ["known", "unknown"] } }],
66
+ }),
67
+ );
68
+ expect(kindsOf(outcome).warnings).toEqual(["sub-agent-skill-unknown"]);
69
+ expect(accepted(outcome).subAgents[0]?.skillNames).toEqual(["known"]);
70
+ });
71
+
72
+ it("classifies model into an alias and warns on one it cannot map", () => {
73
+ const outcome = read(
74
+ cursorPlugin({
75
+ agents: [
76
+ { file: "fast", frontmatter: { model: "fast" } },
77
+ { file: "sonnet", frontmatter: { model: "claude-sonnet-4-5" } },
78
+ { file: "inherit", frontmatter: { model: "inherit" } },
79
+ { file: "grok", frontmatter: { model: "grok-4.6[effort=xhigh]" } },
80
+ { file: "none", frontmatter: {} },
81
+ ],
82
+ }),
83
+ );
84
+ expect(kindsOf(outcome).warnings).toEqual(["sub-agent-model-unknown"]);
85
+ const hints = accepted(outcome).subAgents.map((a) => [a.name, a.modelHint?.alias]);
86
+ expect(hints).toEqual([
87
+ ["fast", "fast"],
88
+ ["grok", "unknown"],
89
+ ["inherit", "inherit"],
90
+ ["none", undefined],
91
+ ["sonnet", "sonnet"],
92
+ ]);
93
+ });
94
+
95
+ it("warns once per field Stigmer does not read", () => {
96
+ const outcome = read(cursorPlugin({ agents: [{ file: "a", frontmatter: { readonly: true, is_background: false, effort: "high" } }] }));
97
+ expect(kindsOf(outcome).warnings).toEqual(["sub-agent-field-ignored", "sub-agent-field-ignored", "sub-agent-field-ignored"]);
98
+ });
99
+ });
100
+
101
+ describe("classifyModel", () => {
102
+ it("maps the dialects' spellings onto the alias set", () => {
103
+ expect(classifyModel("fast").alias).toBe("fast");
104
+ expect(classifyModel("Sonnet").alias).toBe("sonnet");
105
+ expect(classifyModel("claude-opus-4-1").alias).toBe("opus");
106
+ expect(classifyModel("claude-haiku-4.5").alias).toBe("haiku");
107
+ expect(classifyModel("inherit").alias).toBe("inherit");
108
+ expect(classifyModel("gpt-5").alias).toBe("unknown");
109
+ expect(classifyModel("gpt-5").raw).toBe("gpt-5");
110
+ });
111
+ });