@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,427 @@
1
+ /**
2
+ * MCP server entries, from every configuration source, into the two shapes
3
+ * `McpServerSpec` takes.
4
+ *
5
+ * The open format and the vendor dialects differ on strictness and the
6
+ * source's dialect decides: an open `mcp.json` requires `type` and closes
7
+ * each transport's field set (an unknown field refuses the entry), while a
8
+ * vendor `.mcp.json` infers the transport from `command` or `url` and
9
+ * carries fields Stigmer does not read as warnings (Cursor's undocumented
10
+ * `auth` block has its own, because OAuth on Stigmer has its own home in the
11
+ * `ai.stigmer/` overlay). `streamable-http` and `http` are one transport;
12
+ * the legacy `sse` maps to it with a warning, because the runner connects
13
+ * both with Streamable HTTP and its adapter falls back to SSE when the
14
+ * server rejects that.
15
+ *
16
+ * Four refusals are facts about the runner, not preferences: it mounts no
17
+ * plugin files (so a `./`-relative command, any plugin-root placeholder,
18
+ * and any `cwd` can never resolve); it sends a server's `url` as written
19
+ * (so a `${VAR}` there would be sent literally); and it hands a stdio
20
+ * subprocess exactly the variables the spec declares, by name (so an `env`
21
+ * entry is representable only as `KEY: "${KEY}"`).
22
+ *
23
+ * Every `${VAR}` in a header, an argument, an `env` value or an `auth` value
24
+ * is a reference to the caller's Environment, listed on the server's `env`;
25
+ * the variables module declares the undeclared ones. Claude's
26
+ * `${user_config.KEY}` is rewritten to `${KEY}` before anything is scanned.
27
+ */
28
+
29
+ import type { McpConfigSource } from "../dialects/manifest.js";
30
+ import {
31
+ describeValue,
32
+ fields,
33
+ isJsonObject,
34
+ isStringArray,
35
+ isStringRecord,
36
+ type JsonObject,
37
+ parseJsonObject,
38
+ readText,
39
+ } from "../documents.js";
40
+ import type { PluginFileIndex } from "../files.js";
41
+ import { AGENT_PLUGINS_MCP_SCHEMA, type Findings } from "../messages.js";
42
+ import {
43
+ findPluginRootPlaceholder,
44
+ PLUGIN_ROOT_ENV_NAMES,
45
+ referencedVariables,
46
+ rewriteUserConfig,
47
+ singlePlaceholderName,
48
+ VARIABLE_NAME_PATTERN,
49
+ } from "../placeholders.js";
50
+ import type { PluginMcpServer } from "../types.js";
51
+
52
+ const OPEN_CONFIG_FIELDS: ReadonlySet<string> = new Set(["$schema", "mcpServers"]);
53
+ const OPEN_STDIO_FIELDS: ReadonlySet<string> = new Set(["type", "command", "args", "env", "cwd"]);
54
+ const OPEN_HTTP_FIELDS: ReadonlySet<string> = new Set(["type", "url", "headers"]);
55
+ const VENDOR_STDIO_FIELDS: ReadonlySet<string> = new Set(["type", "command", "args", "env", "cwd"]);
56
+ const VENDOR_HTTP_FIELDS: ReadonlySet<string> = new Set(["type", "url", "headers"]);
57
+
58
+ // RFC 9110 token characters, the set a header field name may use.
59
+ const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
60
+
61
+ type Transport = "stdio" | "http";
62
+
63
+ export interface McpServersResult {
64
+ readonly servers: readonly PluginMcpServer[];
65
+ /**
66
+ * How many entries were refused. The variables stage reads it: a refused
67
+ * server's references are unknown, so "declared but unreferenced" would be
68
+ * a consequence of the refusal rather than advice, and is not warned.
69
+ */
70
+ readonly refused: number;
71
+ /**
72
+ * Every server name the sources declared, accepted or refused. The overlay
73
+ * checks against this, so a refused server's overlay is not a second refusal.
74
+ */
75
+ readonly declaredNames: ReadonlySet<string>;
76
+ }
77
+
78
+ export function normaliseMcpServers(index: PluginFileIndex, sources: readonly McpConfigSource[], findings: Findings): McpServersResult {
79
+ const servers: PluginMcpServer[] = [];
80
+ const seen = new Set<string>();
81
+ let refused = 0;
82
+ for (const source of sources) {
83
+ const entries = readSource(index, source, findings);
84
+ if (entries === undefined) {
85
+ refused++;
86
+ continue;
87
+ }
88
+ for (const [name, raw] of fields(entries.servers)) {
89
+ if (seen.has(name)) {
90
+ findings.error("mcp-server-name-duplicate", { subject: name, path: entries.path });
91
+ refused++;
92
+ continue;
93
+ }
94
+ seen.add(name);
95
+ const server = readServer(name, raw, entries.path, source.dialect === "agent-plugins", findings);
96
+ if (server !== undefined) servers.push(server);
97
+ else refused++;
98
+ }
99
+ }
100
+ return { servers, refused, declaredNames: seen };
101
+ }
102
+
103
+ interface ServerEntries {
104
+ readonly path: string;
105
+ readonly servers: JsonObject;
106
+ }
107
+
108
+ function readSource(index: PluginFileIndex, source: McpConfigSource, findings: Findings): ServerEntries | undefined {
109
+ const open = source.dialect === "agent-plugins";
110
+ if (source.kind === "inline") {
111
+ // An inline value is either the whole configuration shape or the
112
+ // server map itself; both are seen in the wild and both mean one thing.
113
+ const wrapped = isJsonObject(source.servers) ? source.servers["mcpServers"] : undefined;
114
+ const servers = isJsonObject(wrapped) ? wrapped : source.servers;
115
+ if (!isJsonObject(servers)) {
116
+ findings.error("mcp-config-shape", { path: source.manifest });
117
+ return undefined;
118
+ }
119
+ return { path: source.manifest, servers };
120
+ }
121
+
122
+ if (!index.has(source.path)) {
123
+ findings.warn("path-missing", { path: source.manifest, subject: `./${source.path}` });
124
+ return undefined;
125
+ }
126
+ const text = readText(index, source.path, "mcpConfig", findings);
127
+ if (text === undefined) return undefined;
128
+ const object = parseJsonObject(text, source.path, "mcp-config-unreadable", findings);
129
+ if (object === undefined) return undefined;
130
+
131
+ if (open) {
132
+ const schema = object["$schema"];
133
+ if (schema === undefined) {
134
+ findings.error("mcp-config-schema-missing", { path: source.path });
135
+ } else if (schema !== AGENT_PLUGINS_MCP_SCHEMA) {
136
+ findings.error("mcp-config-schema-unsupported", { path: source.path, detail: describeValue(schema) });
137
+ }
138
+ }
139
+ for (const [name] of fields(object)) {
140
+ if (OPEN_CONFIG_FIELDS.has(name)) continue;
141
+ if (open) findings.error("mcp-config-field-unknown", { path: source.path, subject: name });
142
+ else findings.warn("mcp-config-field-ignored", { path: source.path, subject: name });
143
+ }
144
+ const servers = object["mcpServers"];
145
+ if (!isJsonObject(servers)) {
146
+ findings.error("mcp-config-shape", { path: source.path });
147
+ return undefined;
148
+ }
149
+ return { path: source.path, servers };
150
+ }
151
+
152
+ function readServer(name: string, raw: unknown, path: string, open: boolean, findings: Findings): PluginMcpServer | undefined {
153
+ const ctx = { subject: name, path };
154
+ if (!isJsonObject(raw)) {
155
+ findings.error("mcp-server-shape", ctx);
156
+ return undefined;
157
+ }
158
+ const transport = resolveTransport(raw, ctx, open, findings);
159
+ if (transport === undefined) return undefined;
160
+
161
+ const known = transport === "stdio" ? (open ? OPEN_STDIO_FIELDS : VENDOR_STDIO_FIELDS) : open ? OPEN_HTTP_FIELDS : VENDOR_HTTP_FIELDS;
162
+ const references = new Set<string>();
163
+ let valid = true;
164
+ for (const [field, value] of fields(raw)) {
165
+ if (known.has(field)) continue;
166
+ if (open) {
167
+ findings.error("mcp-server-field-unknown", { ...ctx, detail: field });
168
+ valid = false;
169
+ } else if (field === "auth") {
170
+ findings.warn("mcp-server-auth-ignored", ctx);
171
+ for (const text of stringsWithin(value)) collectReferences(text, references);
172
+ } else {
173
+ findings.warn("mcp-server-field-ignored", { ...ctx, detail: field });
174
+ }
175
+ }
176
+ if (!valid) return undefined;
177
+
178
+ return transport === "stdio" ? readStdio(name, raw, ctx, references, findings) : readHttp(name, raw, ctx, references, findings);
179
+ }
180
+
181
+ interface Ctx {
182
+ readonly subject: string;
183
+ readonly path: string;
184
+ }
185
+
186
+ function resolveTransport(raw: JsonObject, ctx: Ctx, open: boolean, findings: Findings): Transport | undefined {
187
+ const type = raw["type"];
188
+ if (type !== undefined && typeof type !== "string") {
189
+ findings.error("mcp-server-field-type", { ...ctx, detail: "type" });
190
+ return undefined;
191
+ }
192
+ if (type === undefined) {
193
+ if (open) {
194
+ findings.error("mcp-server-type-missing", ctx);
195
+ return undefined;
196
+ }
197
+ const hasCommand = raw["command"] !== undefined;
198
+ const hasUrl = raw["url"] !== undefined;
199
+ if (hasCommand && hasUrl) {
200
+ findings.error("mcp-server-type-ambiguous", ctx);
201
+ return undefined;
202
+ }
203
+ if (hasCommand) return "stdio";
204
+ if (hasUrl) return "http";
205
+ findings.error("mcp-server-transport-unknown", ctx);
206
+ return undefined;
207
+ }
208
+ switch (type) {
209
+ case "stdio":
210
+ return "stdio";
211
+ case "streamable-http":
212
+ return "http";
213
+ case "http":
214
+ if (open) {
215
+ findings.error("mcp-server-type-unknown", { ...ctx, detail: type });
216
+ return undefined;
217
+ }
218
+ return "http";
219
+ case "sse":
220
+ findings.warn("mcp-server-sse-mapped", ctx);
221
+ return "http";
222
+ default:
223
+ findings.error("mcp-server-type-unknown", { ...ctx, detail: type });
224
+ return undefined;
225
+ }
226
+ }
227
+
228
+ function readStdio(name: string, raw: JsonObject, ctx: Ctx, references: Set<string>, findings: Findings): PluginMcpServer | undefined {
229
+ let valid = true;
230
+ const fail = (): void => {
231
+ valid = false;
232
+ };
233
+
234
+ const command = raw["command"];
235
+ if (command === undefined) {
236
+ findings.error("mcp-server-command-missing", ctx);
237
+ fail();
238
+ } else if (typeof command !== "string") {
239
+ findings.error("mcp-server-field-type", { ...ctx, detail: "command" });
240
+ fail();
241
+ } else {
242
+ const root = findPluginRootPlaceholder(command);
243
+ if (root !== undefined) {
244
+ findings.error("mcp-server-plugin-root-reference", { ...ctx, detail: root });
245
+ fail();
246
+ } else if (command.startsWith("./")) {
247
+ findings.error("mcp-server-command-relative", { ...ctx, detail: command });
248
+ fail();
249
+ } else if (command === "" || /\s/.test(command) || command.includes("/")) {
250
+ findings.error("mcp-server-command-invalid", ctx);
251
+ fail();
252
+ }
253
+ }
254
+
255
+ const args: string[] = [];
256
+ const rawArgs = raw["args"];
257
+ if (rawArgs !== undefined) {
258
+ if (!isStringArray(rawArgs)) {
259
+ findings.error("mcp-server-field-type", { ...ctx, detail: "args" });
260
+ fail();
261
+ } else {
262
+ for (const arg of rawArgs) {
263
+ const rewritten = rewriteUserConfig(arg);
264
+ const root = findPluginRootPlaceholder(rewritten);
265
+ if (root !== undefined) {
266
+ findings.error("mcp-server-plugin-root-reference", { ...ctx, detail: root });
267
+ fail();
268
+ continue;
269
+ }
270
+ collectReferences(rewritten, references);
271
+ args.push(rewritten);
272
+ }
273
+ }
274
+ }
275
+
276
+ const rawEnv = raw["env"];
277
+ if (rawEnv !== undefined) {
278
+ if (!isStringRecord(rawEnv)) {
279
+ findings.error("mcp-server-field-type", { ...ctx, detail: "env" });
280
+ fail();
281
+ } else {
282
+ for (const [key, value] of Object.entries(rawEnv)) {
283
+ if (PLUGIN_ROOT_ENV_NAMES.has(key)) {
284
+ findings.error("mcp-server-plugin-root-reference", { ...ctx, detail: key });
285
+ fail();
286
+ continue;
287
+ }
288
+ if (!VARIABLE_NAME_PATTERN.test(key)) {
289
+ findings.error("variable-name-invalid", { subject: key, path: ctx.path });
290
+ fail();
291
+ continue;
292
+ }
293
+ const rewritten = rewriteUserConfig(value);
294
+ const root = findPluginRootPlaceholder(rewritten);
295
+ if (root !== undefined) {
296
+ findings.error("mcp-server-plugin-root-reference", { ...ctx, detail: root });
297
+ fail();
298
+ continue;
299
+ }
300
+ const referenced = singlePlaceholderName(rewritten);
301
+ if (referenced === undefined) {
302
+ findings.error("mcp-server-env-literal", { ...ctx, detail: key });
303
+ fail();
304
+ } else if (referenced !== key) {
305
+ findings.error("mcp-server-env-rename", { ...ctx, detail: key });
306
+ fail();
307
+ } else {
308
+ references.add(key);
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ if (raw["cwd"] !== undefined) {
315
+ findings.error("mcp-server-cwd-unsupported", ctx);
316
+ fail();
317
+ }
318
+
319
+ if (!valid || typeof command !== "string") return undefined;
320
+ return { name, transport: "stdio", command, args, env: [...references].sort() };
321
+ }
322
+
323
+ function readHttp(name: string, raw: JsonObject, ctx: Ctx, references: Set<string>, findings: Findings): PluginMcpServer | undefined {
324
+ let valid = true;
325
+ const fail = (): void => {
326
+ valid = false;
327
+ };
328
+
329
+ let url: string | undefined;
330
+ const rawUrl = raw["url"];
331
+ if (rawUrl === undefined) {
332
+ findings.error("mcp-server-url-missing", ctx);
333
+ fail();
334
+ } else if (typeof rawUrl !== "string") {
335
+ findings.error("mcp-server-field-type", { ...ctx, detail: "url" });
336
+ fail();
337
+ } else {
338
+ url = rewriteUserConfig(rawUrl);
339
+ const root = findPluginRootPlaceholder(url);
340
+ if (root !== undefined) {
341
+ findings.error("mcp-server-plugin-root-reference", { ...ctx, detail: root });
342
+ fail();
343
+ } else if (referencedVariables(url).length > 0) {
344
+ findings.error("mcp-server-url-variable", ctx);
345
+ fail();
346
+ } else if (!isAcceptableServerUrl(url)) {
347
+ findings.error("mcp-server-url-invalid", { ...ctx, detail: url });
348
+ fail();
349
+ }
350
+ }
351
+
352
+ // Collected as pairs and materialised with `Object.fromEntries`, which
353
+ // defines own properties: a header literally named `__proto__` (a valid
354
+ // token) must become a key, never a prototype assignment.
355
+ const headers: [string, string][] = [];
356
+ const rawHeaders = raw["headers"];
357
+ if (rawHeaders !== undefined) {
358
+ if (!isStringRecord(rawHeaders)) {
359
+ findings.error("mcp-server-field-type", { ...ctx, detail: "headers" });
360
+ fail();
361
+ } else {
362
+ const lowered = new Set<string>();
363
+ for (const [header, value] of Object.entries(rawHeaders)) {
364
+ if (!HEADER_NAME_PATTERN.test(header)) {
365
+ findings.error("mcp-server-header-invalid", { ...ctx, detail: header });
366
+ fail();
367
+ continue;
368
+ }
369
+ const key = header.toLowerCase();
370
+ if (lowered.has(key)) {
371
+ findings.error("mcp-server-header-duplicate", { ...ctx, detail: header });
372
+ fail();
373
+ continue;
374
+ }
375
+ lowered.add(key);
376
+ const rewritten = rewriteUserConfig(value);
377
+ const root = findPluginRootPlaceholder(rewritten);
378
+ if (root !== undefined) {
379
+ findings.error("mcp-server-plugin-root-reference", { ...ctx, detail: root });
380
+ fail();
381
+ continue;
382
+ }
383
+ collectReferences(rewritten, references);
384
+ headers.push([header, rewritten]);
385
+ }
386
+ }
387
+ }
388
+
389
+ if (!valid || url === undefined) return undefined;
390
+ return { name, transport: "http", url, headers: Object.fromEntries(headers), env: [...references].sort() };
391
+ }
392
+
393
+ /**
394
+ * The open format's URL rule: absolute HTTP(S), no user information, no
395
+ * fragment, HTTPS unless the host is loopback. Applied in every dialect
396
+ * because the runner's `HttpServerConfig.url` must be a URI and a plugin
397
+ * that fails here would fail there.
398
+ */
399
+ function isAcceptableServerUrl(value: string): boolean {
400
+ let url: URL;
401
+ try {
402
+ url = new URL(value);
403
+ } catch {
404
+ return false;
405
+ }
406
+ if (url.username !== "" || url.password !== "" || url.hash !== "") return false;
407
+ if (url.protocol === "https:") return true;
408
+ if (url.protocol !== "http:") return false;
409
+ return isLoopbackHost(url.hostname);
410
+ }
411
+
412
+ function isLoopbackHost(hostname: string): boolean {
413
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
414
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
415
+ }
416
+
417
+ function collectReferences(text: string, into: Set<string>): void {
418
+ for (const name of referencedVariables(text)) into.add(name);
419
+ }
420
+
421
+ /** Every string nested anywhere in a JSON value (the `auth` block's values). */
422
+ function stringsWithin(value: unknown): readonly string[] {
423
+ if (typeof value === "string") return [rewriteUserConfig(value)];
424
+ if (Array.isArray(value)) return value.flatMap(stringsWithin);
425
+ if (isJsonObject(value)) return Object.values(value).flatMap(stringsWithin);
426
+ return [];
427
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Stigmer's own extension folder, `ai.stigmer/`: located and checked here,
3
+ * parsed by the installer.
4
+ *
5
+ * The open format gives each client a reverse-domain directory other clients
6
+ * ignore; Stigmer's carries what no portable component can say: a full
7
+ * `Agent` that replaces the composed default (`agent.yaml`), `Workflow`s
8
+ * (`workflows/<name>.yaml`), and the richer `McpServer` overlay for a
9
+ * declared server (`mcp-servers/<server>.yaml`: auth, scope hints, default
10
+ * tools). The library hands these over as bytes with their paths and keeps
11
+ * no knowledge of the resource schemas; the installer parses them where
12
+ * the protos live.
13
+ *
14
+ * Two checks are the library's because they are about the plugin, not the
15
+ * resources: an overlay for a server the plugin does not declare is refused
16
+ * (an overlay that silently applies to nothing is the drift the model
17
+ * exists to prevent), and a file under `ai.stigmer/` that is none of the
18
+ * three document shapes is refused for the same reason (`agent.yml` would
19
+ * otherwise be a typo that installs the composed default).
20
+ */
21
+
22
+ import { readBytes } from "../documents.js";
23
+ import { basename, dirname, type PluginFileIndex } from "../files.js";
24
+ import type { Findings } from "../messages.js";
25
+ import type { OverlayNamedDocument, OverlayServerDocument, StigmerOverlay } from "../types.js";
26
+
27
+ export const OVERLAY_DIR = "ai.stigmer";
28
+ const AGENT_DOCUMENT = `${OVERLAY_DIR}/agent.yaml`;
29
+ const WORKFLOWS_DIR = `${OVERLAY_DIR}/workflows`;
30
+ const MCP_SERVERS_DIR = `${OVERLAY_DIR}/mcp-servers`;
31
+
32
+ /** `serverNames` is every server the plugin DECLARED, refused ones included, so a refused server's overlay is not blamed twice. */
33
+ export function normaliseOverlay(index: PluginFileIndex, serverNames: ReadonlySet<string>, findings: Findings): StigmerOverlay {
34
+ const overlay: { -readonly [K in keyof StigmerOverlay]: StigmerOverlay[K] } = { workflows: [], mcpServers: [] };
35
+ const workflows: OverlayNamedDocument[] = [];
36
+ const mcpServers: OverlayServerDocument[] = [];
37
+
38
+ for (const path of index.filesUnder(OVERLAY_DIR)) {
39
+ if (path === AGENT_DOCUMENT) {
40
+ const bytes = readBytes(index, path, "overlay", findings);
41
+ if (bytes !== undefined) overlay.agent = { path, bytes };
42
+ continue;
43
+ }
44
+ const name = yamlStem(path);
45
+ if (name !== undefined && dirname(path) === WORKFLOWS_DIR) {
46
+ const bytes = readBytes(index, path, "overlay", findings);
47
+ if (bytes !== undefined) workflows.push({ path, bytes, name });
48
+ continue;
49
+ }
50
+ if (name !== undefined && dirname(path) === MCP_SERVERS_DIR) {
51
+ if (!serverNames.has(name)) {
52
+ findings.error("overlay-server-unknown", { path, subject: name });
53
+ continue;
54
+ }
55
+ const bytes = readBytes(index, path, "overlay", findings);
56
+ if (bytes !== undefined) mcpServers.push({ path, bytes, server: name });
57
+ continue;
58
+ }
59
+ findings.error("overlay-document-unknown", { path });
60
+ }
61
+
62
+ overlay.workflows = workflows;
63
+ overlay.mcpServers = mcpServers;
64
+ return overlay;
65
+ }
66
+
67
+ /** The file stem of a `.yaml` document, or `undefined` for any other file. */
68
+ function yamlStem(path: string): string | undefined {
69
+ const name = basename(path);
70
+ return name.endsWith(".yaml") && name.length > ".yaml".length ? name.slice(0, -".yaml".length) : undefined;
71
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Skill discovery and the `SKILL.md` frontmatter check.
3
+ *
4
+ * Discovery follows the union of the dialects' rules: the immediate child
5
+ * directories of `skills/` that hold a `SKILL.md` (the open format's fixed
6
+ * location, no recursion), plus every path a manifest declared (Claude adds
7
+ * to the default; Cursor's value is the default in practice), where a
8
+ * declared path is a skill when it holds `SKILL.md` directly and a directory
9
+ * of skills otherwise, plus Claude's single-skill layout (a root `SKILL.md`
10
+ * with no `skills/` and no declaration). A declared path that names nothing
11
+ * is a warning: Cursor's own tooling does not refuse it, and refusing would
12
+ * turn a stale manifest line into a failed install.
13
+ *
14
+ * The frontmatter check is a discovery check, not the push gate: it asks
15
+ * that the block be present and closed, that it parse, and that `name`
16
+ * satisfy `SKILL_NAME_PATTERN`. A missing `name` falls back to the directory
17
+ * name (Claude's rule) with a warning; the Agent Skills rules Stigmer relaxes
18
+ * (`name` equal to the directory, `description` present) are warnings.
19
+ * Optional frontmatter (`license`, `compatibility`, `metadata`,
20
+ * `allowed-tools`) and vendor keys ride inside the skill untouched. Every
21
+ * file under the skill directory is listed for the installer's per-skill
22
+ * archive; only `SKILL.md` is ever read.
23
+ */
24
+
25
+ import type { ManifestSet } from "../detect.js";
26
+ import { readText } from "../documents.js";
27
+ import { basename, comparePaths, joinPath, type PluginFileIndex } from "../files.js";
28
+ import { extractFrontmatter, parseFrontmatter, SKILL_NAME_PATTERN } from "../frontmatter.js";
29
+ import type { Findings } from "../messages.js";
30
+ import type { PluginSkill } from "../types.js";
31
+
32
+ export const SKILL_FILE = "SKILL.md";
33
+ export const DEFAULT_SKILLS_DIR = "skills";
34
+
35
+ export function normaliseSkills(index: PluginFileIndex, set: ManifestSet, findings: Findings): readonly PluginSkill[] {
36
+ const skills: PluginSkill[] = [];
37
+ const seenNames = new Map<string, string>();
38
+
39
+ for (const dir of discoverSkillDirs(index, set, findings)) {
40
+ const skill = readSkill(index, dir, set, findings);
41
+ if (skill === undefined) continue;
42
+ const previous = seenNames.get(skill.name);
43
+ if (previous !== undefined) {
44
+ findings.error("skill-name-duplicate", { subject: skill.name, path: joinPath(dir, SKILL_FILE) });
45
+ continue;
46
+ }
47
+ seenNames.set(skill.name, dir);
48
+ skills.push(skill);
49
+ }
50
+ return skills;
51
+ }
52
+
53
+ /** Skill directories, deduplicated, in path order; the root skill is `""`. */
54
+ function discoverSkillDirs(index: PluginFileIndex, set: ManifestSet, findings: Findings): readonly string[] {
55
+ const dirs = new Set<string>();
56
+ const addSkillsUnder = (parent: string): void => {
57
+ for (const child of index.childDirectories(parent)) {
58
+ const dir = joinPath(parent, child);
59
+ if (index.has(joinPath(dir, SKILL_FILE))) dirs.add(dir);
60
+ }
61
+ };
62
+
63
+ addSkillsUnder(DEFAULT_SKILLS_DIR);
64
+
65
+ const declared = set.manifests.flatMap((m) => m.skillPaths);
66
+ for (const { path, manifest } of declared) {
67
+ if (index.has(joinPath(path, SKILL_FILE))) {
68
+ dirs.add(path);
69
+ } else if (index.isDirectory(path)) {
70
+ addSkillsUnder(path);
71
+ } else {
72
+ findings.warn("path-missing", { path: manifest, subject: path === "" ? "." : `./${path}` });
73
+ }
74
+ }
75
+
76
+ if (declared.length === 0 && !index.isDirectory(DEFAULT_SKILLS_DIR) && index.has(SKILL_FILE)) {
77
+ dirs.add("");
78
+ }
79
+
80
+ return [...dirs].sort(comparePaths);
81
+ }
82
+
83
+ function readSkill(index: PluginFileIndex, dir: string, set: ManifestSet, findings: Findings): PluginSkill | undefined {
84
+ const path = joinPath(dir, SKILL_FILE);
85
+ const text = readText(index, path, "skillMd", findings);
86
+ if (text === undefined) return undefined;
87
+
88
+ const extracted = extractFrontmatter(text);
89
+ if (!extracted.ok) {
90
+ findings.error(extracted.reason === "missing" ? "skill-frontmatter-missing" : "skill-frontmatter-unclosed", { path });
91
+ return undefined;
92
+ }
93
+ const parsed = parseFrontmatter(extracted.yaml);
94
+ if (!parsed.ok) {
95
+ findings.error("skill-frontmatter-unreadable", { path, detail: parsed.detail });
96
+ return undefined;
97
+ }
98
+
99
+ const directoryName = dir === "" ? (set.name ?? "skill") : basename(dir);
100
+ let name: string;
101
+ if (typeof parsed.fields["name"] === "string" && parsed.fields["name"] !== "") {
102
+ name = parsed.fields["name"];
103
+ } else {
104
+ name = directoryName;
105
+ findings.warn("skill-name-defaulted", { path, subject: name });
106
+ }
107
+ if (!SKILL_NAME_PATTERN.test(name)) {
108
+ findings.error("skill-name-invalid", { path, subject: name });
109
+ return undefined;
110
+ }
111
+ if (dir !== "" && name !== directoryName) {
112
+ findings.warn("skill-name-differs-from-directory", { path, subject: name, detail: directoryName });
113
+ }
114
+
115
+ const description = parsed.fields["description"];
116
+ if (typeof description !== "string" || description === "") {
117
+ findings.warn("skill-description-missing", { path, subject: name });
118
+ }
119
+
120
+ return {
121
+ name,
122
+ ...(typeof description === "string" && description !== "" && { description }),
123
+ dir,
124
+ files: index.filesUnder(dir),
125
+ };
126
+ }