@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,237 @@
1
+ /**
2
+ * The intermediate every dialect reader reduces its manifest to, and the
3
+ * field-reading helpers they share.
4
+ *
5
+ * A `DialectManifest` says what one manifest CONTRIBUTES: identity fields,
6
+ * declared component paths, MCP configuration sources, a variables block,
7
+ * and the fields it carries that Stigmer records as ignored. It does not
8
+ * decide anything across manifests: precedence, name conflicts and default
9
+ * locations are `detect.ts`'s, and turning declarations into skills,
10
+ * servers and variables is `normalise/`'s. Keeping the readers to "what
11
+ * does this file say" is what lets a plugin carry a root manifest and a
12
+ * vendor manifest at once and have both read.
13
+ *
14
+ * Unknown fields WARN in every dialect (the open format says report and
15
+ * ignore; Cursor's own validator would reject them, but a warning serves
16
+ * the author and the install still works). A known field of the wrong type
17
+ * is fatal (`manifest-field-type`), the open format's rule for any
18
+ * violation other than an unknown field.
19
+ */
20
+
21
+ import { fields, isJsonObject, type JsonObject, optionalString, optionalStringArray, stringOrStringArray } from "../documents.js";
22
+ import { resolveDeclaredPath } from "../files.js";
23
+ import type { Findings } from "../messages.js";
24
+ import type { IgnoredComponent, IgnoredComponentKind, PluginAuthor, PluginDialect } from "../types.js";
25
+
26
+ export interface PluginIdentity {
27
+ readonly name?: string;
28
+ readonly version?: string;
29
+ readonly description?: string;
30
+ readonly author?: PluginAuthor;
31
+ readonly homepage?: string;
32
+ readonly repository?: string;
33
+ readonly license?: string;
34
+ readonly keywords?: readonly string[];
35
+ }
36
+
37
+ /** A component path a manifest declared, normalised, with where it was declared. */
38
+ export interface DeclaredPath {
39
+ /** Plugin-relative, no leading `./`, no trailing slash; the root is `""`. */
40
+ readonly path: string;
41
+ /** The manifest that declared it, for messages. */
42
+ readonly manifest: string;
43
+ }
44
+
45
+ /** Where MCP server entries come from: a configuration file, or an object inline in a manifest. */
46
+ export type McpConfigSource =
47
+ | { readonly kind: "file"; readonly path: string; readonly dialect: PluginDialect; readonly manifest: string }
48
+ | { readonly kind: "inline"; readonly manifest: string; readonly servers: unknown; readonly dialect: PluginDialect };
49
+
50
+ /** A variables block, raw, tagged with the dialect whose shape it takes. */
51
+ export type VariablesDeclaration =
52
+ | { readonly dialect: "cursor"; readonly value: unknown; readonly manifest: string }
53
+ | { readonly dialect: "claude"; readonly value: unknown; readonly manifest: string };
54
+
55
+ export interface DialectManifest {
56
+ readonly dialect: PluginDialect;
57
+ readonly path: string;
58
+ readonly identity: PluginIdentity;
59
+ /** Declared skill paths; the default `skills/` is scanned regardless. */
60
+ readonly skillPaths: readonly DeclaredPath[];
61
+ /** Declared agent paths; `undefined` leaves the default `agents/` scan in force. */
62
+ readonly agentPaths?: readonly DeclaredPath[];
63
+ readonly mcpConfigs: readonly McpConfigSource[];
64
+ readonly variables?: VariablesDeclaration;
65
+ readonly ignored: readonly IgnoredComponent[];
66
+ }
67
+
68
+ /** Warn once per field the dialect does not define. */
69
+ export function warnUnknownFields(object: JsonObject, known: ReadonlySet<string>, path: string, findings: Findings): void {
70
+ for (const [name] of fields(object)) {
71
+ if (!known.has(name)) findings.warn("manifest-field-unknown", { path, subject: name });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The identity fields every dialect shares. `author` is an object in every
77
+ * dialect (Cursor's schema has no `url`; the extra key is a warning there,
78
+ * reported by the caller's known-field set for `author`).
79
+ */
80
+ export function readIdentity(object: JsonObject, path: string, findings: Findings): PluginIdentity {
81
+ const identity: { -readonly [K in keyof PluginIdentity]: PluginIdentity[K] } = {};
82
+ const name = optionalString(object, "name", path, findings);
83
+ if (name !== undefined) identity.name = name;
84
+ const version = optionalString(object, "version", path, findings);
85
+ if (version !== undefined) identity.version = version;
86
+ const description = optionalString(object, "description", path, findings);
87
+ if (description !== undefined) identity.description = description;
88
+ const homepage = optionalString(object, "homepage", path, findings);
89
+ if (homepage !== undefined) identity.homepage = homepage;
90
+ const repository = optionalString(object, "repository", path, findings);
91
+ if (repository !== undefined) identity.repository = repository;
92
+ const license = optionalString(object, "license", path, findings);
93
+ if (license !== undefined) identity.license = license;
94
+ const keywords = optionalStringArray(object, "keywords", path, findings);
95
+ if (keywords !== undefined) identity.keywords = keywords;
96
+ const author = readAuthor(object, path, findings);
97
+ if (author !== undefined) identity.author = author;
98
+ return identity;
99
+ }
100
+
101
+ function readAuthor(object: JsonObject, path: string, findings: Findings): PluginAuthor | undefined {
102
+ const value = object["author"];
103
+ if (value === undefined) return undefined;
104
+ if (!isJsonObject(value)) {
105
+ findings.error("manifest-field-type", { path, subject: "author", detail: "an object" });
106
+ return undefined;
107
+ }
108
+ const author: { -readonly [K in keyof PluginAuthor]: PluginAuthor[K] } = {};
109
+ const name = optionalString(value, "name", path, findings);
110
+ if (name !== undefined) author.name = name;
111
+ const email = optionalString(value, "email", path, findings);
112
+ if (email !== undefined) author.email = email;
113
+ const url = optionalString(value, "url", path, findings);
114
+ if (url !== undefined) author.url = url;
115
+ return author;
116
+ }
117
+
118
+ /**
119
+ * A path field (`skills`, `agents`, ...) as declared paths. A value that is
120
+ * not a path (wrong prefix, escape, glob) is reported and dropped; the
121
+ * rest are kept, so one bad entry does not hide the others.
122
+ */
123
+ export function readDeclaredPaths(
124
+ object: JsonObject,
125
+ field: string,
126
+ path: string,
127
+ findings: Findings,
128
+ ): readonly DeclaredPath[] | undefined {
129
+ const values = stringOrStringArray(object, field, path, findings);
130
+ if (values === undefined) return undefined;
131
+ const declared: DeclaredPath[] = [];
132
+ for (const value of values) {
133
+ const resolved = resolveDeclaredPath(value);
134
+ if (!resolved.ok) {
135
+ findings.error(resolved.kind, { path, subject: value });
136
+ continue;
137
+ }
138
+ declared.push({ path: resolved.path, manifest: path });
139
+ }
140
+ return declared;
141
+ }
142
+
143
+ /**
144
+ * A vendor `mcpServers` field: a path, an inline `mcpServers` object, or an
145
+ * array of either. Each becomes one source under the dialect's rules.
146
+ */
147
+ export function readMcpSources(
148
+ object: JsonObject,
149
+ field: string,
150
+ path: string,
151
+ dialect: PluginDialect,
152
+ findings: Findings,
153
+ ): readonly McpConfigSource[] {
154
+ const value = object[field];
155
+ if (value === undefined) return [];
156
+ const items = Array.isArray(value) ? value : [value];
157
+ const sources: McpConfigSource[] = [];
158
+ for (const item of items) {
159
+ if (typeof item === "string") {
160
+ const resolved = resolveDeclaredPath(item);
161
+ if (!resolved.ok) {
162
+ findings.error(resolved.kind, { path, subject: item });
163
+ continue;
164
+ }
165
+ sources.push({ kind: "file", path: resolved.path, dialect, manifest: path });
166
+ } else if (isJsonObject(item)) {
167
+ sources.push({ kind: "inline", manifest: path, servers: item, dialect });
168
+ } else {
169
+ findings.error("manifest-field-type", { path, subject: field, detail: "a path, an object, or an array of either" });
170
+ }
171
+ }
172
+ return sources;
173
+ }
174
+
175
+ /**
176
+ * Manifest fields Stigmer reads past, recorded as ignored components at
177
+ * `<manifest>#<field>` when present. Shared across dialects; each names the
178
+ * fields it knows through `known` so the same field is never also an
179
+ * unknown-field warning.
180
+ */
181
+ export const IGNORED_FIELD_KINDS: Readonly<Record<string, IgnoredComponentKind>> = {
182
+ hooks: "hooks",
183
+ rules: "rules",
184
+ commands: "commands",
185
+ workflows: "workflows",
186
+ outputStyles: "output-styles",
187
+ lspServers: "lsp-servers",
188
+ channels: "channels",
189
+ dependencies: "dependencies",
190
+ defaultEnabled: "default-enabled",
191
+ minClientVersions: "min-client-versions",
192
+ logo: "logo",
193
+ apps: "apps",
194
+ };
195
+
196
+ export function ignoredFieldComponents(object: JsonObject, path: string): IgnoredComponent[] {
197
+ const ignored: IgnoredComponent[] = [];
198
+ for (const [name, value] of fields(object)) {
199
+ const kind = IGNORED_FIELD_KINDS[name];
200
+ if (kind !== undefined) ignored.push({ kind, path: `${path}#${name}` });
201
+ if (name === "experimental" && isJsonObject(value)) {
202
+ for (const [sub] of fields(value)) {
203
+ const subKind = EXPERIMENTAL_KINDS[sub];
204
+ if (subKind !== undefined) ignored.push({ kind: subKind, path: `${path}#experimental.${sub}` });
205
+ }
206
+ }
207
+ }
208
+ return ignored;
209
+ }
210
+
211
+ const EXPERIMENTAL_KINDS: Readonly<Record<string, IgnoredComponentKind>> = {
212
+ themes: "themes",
213
+ monitors: "monitors",
214
+ evals: "evals",
215
+ };
216
+
217
+ /**
218
+ * `extensions` in a root manifest: every namespace with content is an
219
+ * ignored component (a client ignores namespaces it does not implement
220
+ * without validating them); an empty object, including Stigmer's own
221
+ * reserved `ai.stigmer`, is silent. A non-object `extensions` is the open
222
+ * format's one non-fatal type violation: reported and ignored.
223
+ */
224
+ export function extensionComponents(object: JsonObject, path: string, findings: Findings): IgnoredComponent[] {
225
+ const value = object["extensions"];
226
+ if (value === undefined) return [];
227
+ if (!isJsonObject(value)) {
228
+ findings.warn("manifest-extensions-invalid", { path });
229
+ return [];
230
+ }
231
+ const ignored: IgnoredComponent[] = [];
232
+ for (const [namespace, content] of fields(value)) {
233
+ if (isJsonObject(content) && fields(content).length === 0) continue;
234
+ ignored.push({ kind: "extension", path: `${path}#extensions.${namespace}` });
235
+ }
236
+ return ignored;
237
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The Agent Plugins 1.0.0 root manifest (`plugin.json`).
3
+ *
4
+ * The open format's manifest is closed and versioned: `$schema` is required
5
+ * and must be the canonical 1.0.0 identifier, the ten permitted fields are
6
+ * fixed, an unknown field is reported and ignored, and any other violation
7
+ * is fatal. Components live at fixed locations only (`skills/`, `mcp.json`),
8
+ * so this manifest declares no paths and no inline servers; `mcp.json` is
9
+ * read under the open format's rules by `detect.ts` when it exists.
10
+ * Client-specific data belongs under `extensions`, which Stigmer records as
11
+ * ignored per namespace (its own `ai.stigmer` namespace is reserved and
12
+ * empty today).
13
+ */
14
+
15
+ import { describeValue, type JsonObject } from "../documents.js";
16
+ import { AGENT_PLUGINS_MANIFEST_SCHEMA, type Findings } from "../messages.js";
17
+ import { type DialectManifest, extensionComponents, readIdentity, warnUnknownFields } from "./manifest.js";
18
+
19
+ const KNOWN_FIELDS: ReadonlySet<string> = new Set([
20
+ "$schema",
21
+ "name",
22
+ "version",
23
+ "description",
24
+ "author",
25
+ "homepage",
26
+ "repository",
27
+ "license",
28
+ "keywords",
29
+ "extensions",
30
+ ]);
31
+
32
+ export function readOpenManifest(object: JsonObject, path: string, findings: Findings): DialectManifest {
33
+ const schema = object["$schema"];
34
+ if (schema === undefined) {
35
+ findings.error("manifest-schema-missing", { path });
36
+ } else if (schema !== AGENT_PLUGINS_MANIFEST_SCHEMA) {
37
+ findings.error("manifest-schema-unsupported", { path, detail: describeValue(schema) });
38
+ }
39
+ warnUnknownFields(object, KNOWN_FIELDS, path, findings);
40
+ return {
41
+ dialect: "agent-plugins",
42
+ path,
43
+ identity: readIdentity(object, path, findings),
44
+ skillPaths: [],
45
+ mcpConfigs: [],
46
+ ignored: extensionComponents(object, path, findings),
47
+ };
48
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Reading one document through its cap, and the JSON shape checks every
3
+ * dialect reader repeats.
4
+ *
5
+ * `readText` is the only place the library calls `PluginFiles.read`: the
6
+ * declared size is checked against the document class's cap first, the
7
+ * returned length second, and an over-cap document becomes a
8
+ * `document-too-large` finding instead of a read. The JSON helpers turn a
9
+ * parse failure into a finding of the caller's kind and leave the value
10
+ * `unknown`, so each reader narrows fields with the `expect*` helpers and
11
+ * reports a wrong type with the field's name rather than trusting a cast.
12
+ */
13
+
14
+ import { decodeUtf8, PLUGIN_DOCUMENT_LIMITS, type PluginDocumentClass, type PluginFileIndex } from "./files.js";
15
+ import type { Findings } from "./messages.js";
16
+ import type { PluginErrorKind } from "./outcome.js";
17
+
18
+ /** The document's text, or `undefined` after a `document-too-large` finding. */
19
+ export function readText(
20
+ index: PluginFileIndex,
21
+ path: string,
22
+ cls: PluginDocumentClass,
23
+ findings: Findings,
24
+ ): string | undefined {
25
+ const bytes = readBytes(index, path, cls, findings);
26
+ return bytes === undefined ? undefined : decodeUtf8(bytes);
27
+ }
28
+
29
+ /** The document's bytes, or `undefined` after a `document-too-large` finding. */
30
+ export function readBytes(
31
+ index: PluginFileIndex,
32
+ path: string,
33
+ cls: PluginDocumentClass,
34
+ findings: Findings,
35
+ ): Uint8Array | undefined {
36
+ const limit = PLUGIN_DOCUMENT_LIMITS[cls];
37
+ const entry = index.entry(path);
38
+ if (entry === undefined) {
39
+ throw new Error(`plugin file '${path}' is not listed`);
40
+ }
41
+ if (entry.size > limit) {
42
+ findings.error("document-too-large", { path, subject: String(entry.size), detail: String(limit) });
43
+ return undefined;
44
+ }
45
+ const bytes = index.files.read(path);
46
+ if (bytes.length > limit) {
47
+ findings.error("document-too-large", { path, subject: String(bytes.length), detail: String(limit) });
48
+ return undefined;
49
+ }
50
+ return bytes;
51
+ }
52
+
53
+ /** A parsed JSON object (non-null, non-array), or `undefined` after a finding of `kind`. */
54
+ export function parseJsonObject(
55
+ text: string,
56
+ path: string,
57
+ kind: Extract<PluginErrorKind, "manifest-unreadable" | "mcp-config-unreadable">,
58
+ findings: Findings,
59
+ ): JsonObject | undefined {
60
+ let value: unknown;
61
+ try {
62
+ value = JSON.parse(text);
63
+ } catch (error) {
64
+ findings.error(kind, { path, detail: error instanceof Error ? error.message : String(error) });
65
+ return undefined;
66
+ }
67
+ if (!isJsonObject(value)) {
68
+ findings.error(kind, { path, detail: "the document is not a JSON object" });
69
+ return undefined;
70
+ }
71
+ return value;
72
+ }
73
+
74
+ export type JsonObject = Readonly<Record<string, unknown>>;
75
+
76
+ export function isJsonObject(value: unknown): value is JsonObject {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+
80
+ export function isStringArray(value: unknown): value is readonly string[] {
81
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
82
+ }
83
+
84
+ export function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
85
+ return isJsonObject(value) && Object.values(value).every((item) => typeof item === "string");
86
+ }
87
+
88
+ /** A JSON value as a sentence can quote it: a string as itself, anything else serialised. */
89
+ export function describeValue(value: unknown): string {
90
+ return typeof value === "string" ? value : JSON.stringify(value);
91
+ }
92
+
93
+ /**
94
+ * The fields of `object`, in the document's own order. `Object.entries`
95
+ * reads own enumerable properties only, so a `__proto__` key in the JSON is
96
+ * just another entry here and never reaches a prototype.
97
+ */
98
+ export function fields(object: JsonObject): readonly (readonly [string, unknown])[] {
99
+ return Object.entries(object);
100
+ }
101
+
102
+ /** A string field or `undefined`; a present non-string reports `manifest-field-type`. */
103
+ export function optionalString(object: JsonObject, field: string, path: string, findings: Findings): string | undefined {
104
+ const value = object[field];
105
+ if (value === undefined) return undefined;
106
+ if (typeof value !== "string") {
107
+ findings.error("manifest-field-type", { path, subject: field, detail: "a string" });
108
+ return undefined;
109
+ }
110
+ return value;
111
+ }
112
+
113
+ /** A string-array field or `undefined`; a present non-array-of-strings reports `manifest-field-type`. */
114
+ export function optionalStringArray(
115
+ object: JsonObject,
116
+ field: string,
117
+ path: string,
118
+ findings: Findings,
119
+ ): readonly string[] | undefined {
120
+ const value = object[field];
121
+ if (value === undefined) return undefined;
122
+ if (!isStringArray(value)) {
123
+ findings.error("manifest-field-type", { path, subject: field, detail: "an array of strings" });
124
+ return undefined;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * A field that may be one string or an array of strings (the vendor
131
+ * dialects' path fields), normalised to an array; `undefined` when absent.
132
+ */
133
+ export function stringOrStringArray(
134
+ object: JsonObject,
135
+ field: string,
136
+ path: string,
137
+ findings: Findings,
138
+ ): readonly string[] | undefined {
139
+ const value = object[field];
140
+ if (value === undefined) return undefined;
141
+ if (typeof value === "string") return [value];
142
+ if (isStringArray(value)) return value;
143
+ findings.error("manifest-field-type", { path, subject: field, detail: "a string or an array of strings" });
144
+ return undefined;
145
+ }
package/src/files.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The reader the library is pure over, and the path discipline every path in
3
+ * a package obeys.
4
+ *
5
+ * `PluginFiles` is a sorted list of SIZED entries plus `read`. The size is
6
+ * declared up front so the library can refuse an over-cap document before a
7
+ * byte is read: an archive reader may hold a deflated entry whose declared
8
+ * size is the only defence against inflating a bomb, and a cap checked after
9
+ * `read` would be no defence at all. The reader's contract is that `read`
10
+ * never returns more than `size` bytes (a directory reader uses the file's
11
+ * stat size; an archive reader passes the central directory's declared size
12
+ * as its inflate limit). The library checks both sides anyway: the declared
13
+ * size before reading, the returned length after, refusing either as
14
+ * `document-too-large`. Nothing here touches the filesystem or any `node:*`
15
+ * module; the CLI's directory walker and the server's archive reader each
16
+ * implement this interface at their edge.
17
+ *
18
+ * Containment is lexical here and physical in each reader. A listed path
19
+ * that is absolute, contains `..` or a backslash, or has an empty segment is
20
+ * refused by the library; a reader never follows a symlink, and an archive
21
+ * has none. A path DECLARED in a manifest must begin with `./` (or be `.`),
22
+ * the open format's rule for every plugin-relative path.
23
+ */
24
+
25
+ import type { PluginErrorKind } from "./outcome.js";
26
+
27
+ export interface PluginFileEntry {
28
+ /** Plugin-relative POSIX path, no leading `./`, files only. */
29
+ readonly path: string;
30
+ /** The entry's declared size in bytes; `read` returns at most this many. */
31
+ readonly size: number;
32
+ }
33
+
34
+ export interface PluginFiles {
35
+ /** Sorted by path (code-point order), files only, every path contained. */
36
+ readonly entries: readonly PluginFileEntry[];
37
+ /** The bytes of one listed path. */
38
+ read(path: string): Uint8Array;
39
+ }
40
+
41
+ /**
42
+ * Per-document byte caps, checked from declared sizes. A plugin's documents
43
+ * are small by nature; the caps exist so no reader can be made to inflate
44
+ * a large entry through the library, not to bound legitimate content.
45
+ */
46
+ export const PLUGIN_DOCUMENT_LIMITS = {
47
+ /** A manifest (`plugin.json` in any dialect). Real manifests are a few KB. */
48
+ manifest: 256 * 1024,
49
+ /** An MCP configuration file. */
50
+ mcpConfig: 256 * 1024,
51
+ /**
52
+ * A `SKILL.md`. The server's skill push gate inflates a `SKILL.md` alone
53
+ * under the same 1 MB cap, so a skill this library accepts is one the push
54
+ * gate accepts.
55
+ */
56
+ skillMd: 1024 * 1024,
57
+ /** A sub-agent file (`agents/*.md`): a prompt, so the `SKILL.md` cap. */
58
+ subAgent: 1024 * 1024,
59
+ /** A document under `ai.stigmer/`: a resource YAML. */
60
+ overlay: 1024 * 1024,
61
+ } as const;
62
+
63
+ export type PluginDocumentClass = keyof typeof PLUGIN_DOCUMENT_LIMITS;
64
+
65
+ const encoder = new TextEncoder();
66
+ // `fatal: false` keeps the decoder from throwing on a malformed sequence: a
67
+ // document with bad UTF-8 reaches the JSON or YAML parser, which refuses it
68
+ // with its own sentence, rather than crashing the read.
69
+ const decoder = new TextDecoder("utf-8", { fatal: false });
70
+
71
+ /** An in-memory `PluginFiles` over path -> content; the testing builders' product. */
72
+ export function inMemoryPluginFiles(files: ReadonlyMap<string, Uint8Array | string>): PluginFiles {
73
+ const bytes = new Map<string, Uint8Array>();
74
+ for (const [path, content] of files) {
75
+ bytes.set(path, typeof content === "string" ? encoder.encode(content) : content);
76
+ }
77
+ const entries = [...bytes.entries()]
78
+ .map(([path, content]) => ({ path, size: content.length }))
79
+ .sort((a, b) => comparePaths(a.path, b.path));
80
+ return {
81
+ entries,
82
+ read(path) {
83
+ const content = bytes.get(path);
84
+ if (content === undefined) {
85
+ throw new Error(`plugin file '${path}' is not listed`);
86
+ }
87
+ return content;
88
+ },
89
+ };
90
+ }
91
+
92
+ /** Code-point order, the order `entries` is sorted in. */
93
+ export function comparePaths(a: string, b: string): number {
94
+ return a < b ? -1 : a > b ? 1 : 0;
95
+ }
96
+
97
+ /** UTF-8 text with a leading byte-order mark removed (a manifest saved with a BOM was a known load failure elsewhere). */
98
+ export function decodeUtf8(bytes: Uint8Array): string {
99
+ const text = decoder.decode(bytes);
100
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
101
+ }
102
+
103
+ /**
104
+ * Lexical containment of a LISTED path: relative, forward slashes only, no
105
+ * `.` or `..` segments, no empty segments. Readers produce such paths; the
106
+ * library refuses a reader that did not.
107
+ */
108
+ export function isContainedPath(path: string): boolean {
109
+ if (path === "" || path.startsWith("/") || path.includes("\\")) return false;
110
+ return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
111
+ }
112
+
113
+ export type DeclaredPathOutcome =
114
+ | { readonly ok: true; readonly path: string }
115
+ | { readonly ok: false; readonly kind: Extract<PluginErrorKind, "path-not-relative" | "path-escapes-root" | "path-glob-unsupported"> };
116
+
117
+ const GLOB_CHARACTERS = /[*?[\]{}]/;
118
+
119
+ /**
120
+ * Normalise a path DECLARED in a manifest (`"./skills/"`, `"."`,
121
+ * `"./agents/reviewer.md"`) to a plugin-relative path with no leading `./`
122
+ * and no trailing slash; the plugin root itself is the empty string. The
123
+ * open format requires the `./` prefix; the vendor dialects document the
124
+ * same rule; a glob is refused because no plugin in the wild uses one and
125
+ * expanding globs is a second path language the library would then have
126
+ * to own.
127
+ */
128
+ export function resolveDeclaredPath(value: string): DeclaredPathOutcome {
129
+ if (GLOB_CHARACTERS.test(value)) return { ok: false, kind: "path-glob-unsupported" };
130
+ if (value === "." || value === "./") return { ok: true, path: "" };
131
+ if (!value.startsWith("./")) return { ok: false, kind: "path-not-relative" };
132
+ const trimmed = value.slice(2).replace(/\/+$/, "");
133
+ if (trimmed === "") return { ok: true, path: "" };
134
+ if (!isContainedPath(trimmed)) return { ok: false, kind: "path-escapes-root" };
135
+ return { ok: true, path: trimmed };
136
+ }
137
+
138
+ /** `dir + "/" + name`, or `name` at the plugin root. */
139
+ export function joinPath(dir: string, name: string): string {
140
+ return dir === "" ? name : `${dir}/${name}`;
141
+ }
142
+
143
+ /** The last segment of a plugin-relative path. */
144
+ export function basename(path: string): string {
145
+ const slash = path.lastIndexOf("/");
146
+ return slash === -1 ? path : path.slice(slash + 1);
147
+ }
148
+
149
+ /** The path without its last segment; the empty string at the root. */
150
+ export function dirname(path: string): string {
151
+ const slash = path.lastIndexOf("/");
152
+ return slash === -1 ? "" : path.slice(0, slash);
153
+ }
154
+
155
+ /**
156
+ * Indexed access over a `PluginFiles`: membership, the files under a
157
+ * directory, a directory's immediate children. Built once per read; every
158
+ * lookup is a map or a prefix walk over the sorted entries.
159
+ */
160
+ export class PluginFileIndex {
161
+ private readonly byPath: ReadonlyMap<string, PluginFileEntry>;
162
+
163
+ constructor(readonly files: PluginFiles) {
164
+ this.byPath = new Map(files.entries.map((entry) => [entry.path, entry]));
165
+ }
166
+
167
+ has(path: string): boolean {
168
+ return this.byPath.has(path);
169
+ }
170
+
171
+ entry(path: string): PluginFileEntry | undefined {
172
+ return this.byPath.get(path);
173
+ }
174
+
175
+ /** Every file under `dir` (recursively), in entry order; every file at the root when `dir` is empty. */
176
+ filesUnder(dir: string): readonly string[] {
177
+ if (dir === "") return this.files.entries.map((entry) => entry.path);
178
+ const prefix = `${dir}/`;
179
+ return this.files.entries.filter((entry) => entry.path.startsWith(prefix)).map((entry) => entry.path);
180
+ }
181
+
182
+ /** True when at least one file lives under `dir`. */
183
+ isDirectory(dir: string): boolean {
184
+ if (dir === "") return this.files.entries.length > 0;
185
+ const prefix = `${dir}/`;
186
+ return this.files.entries.some((entry) => entry.path.startsWith(prefix));
187
+ }
188
+
189
+ /** The immediate child directory names under `dir`, sorted, deduplicated. */
190
+ childDirectories(dir: string): readonly string[] {
191
+ const prefix = dir === "" ? "" : `${dir}/`;
192
+ const names = new Set<string>();
193
+ for (const entry of this.files.entries) {
194
+ if (!entry.path.startsWith(prefix)) continue;
195
+ const rest = entry.path.slice(prefix.length);
196
+ const slash = rest.indexOf("/");
197
+ if (slash !== -1) names.add(rest.slice(0, slash));
198
+ }
199
+ return [...names].sort(comparePaths);
200
+ }
201
+
202
+ /** The immediate child FILE names under `dir`, sorted. */
203
+ childFiles(dir: string): readonly string[] {
204
+ const prefix = dir === "" ? "" : `${dir}/`;
205
+ const names: string[] = [];
206
+ for (const entry of this.files.entries) {
207
+ if (!entry.path.startsWith(prefix)) continue;
208
+ const rest = entry.path.slice(prefix.length);
209
+ if (!rest.includes("/")) names.push(rest);
210
+ }
211
+ return names.sort(comparePaths);
212
+ }
213
+ }