@stigmer/plugin-package 3.18.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/builtin.d.ts +20 -23
- package/client/builtin.d.ts.map +1 -1
- package/client/builtin.js +19 -36
- package/client/builtin.js.map +1 -1
- package/client/presentation.d.ts +19 -0
- package/client/presentation.d.ts.map +1 -0
- package/client/presentation.js +44 -0
- package/client/presentation.js.map +1 -0
- package/client.d.ts +3 -0
- package/client.d.ts.map +1 -1
- package/client.js +3 -0
- package/client.js.map +1 -1
- package/detect.d.ts +7 -0
- package/detect.d.ts.map +1 -1
- package/detect.js +7 -0
- package/detect.js.map +1 -1
- package/dialects/manifest.d.ts +23 -5
- package/dialects/manifest.d.ts.map +1 -1
- package/dialects/manifest.js +38 -6
- package/dialects/manifest.js.map +1 -1
- package/dialects/open.d.ts +3 -2
- package/dialects/open.d.ts.map +1 -1
- package/dialects/open.js +3 -2
- package/dialects/open.js.map +1 -1
- package/files.d.ts +7 -0
- package/files.d.ts.map +1 -1
- package/files.js +7 -0
- package/files.js.map +1 -1
- package/index.d.ts +3 -1
- package/index.d.ts.map +1 -1
- package/index.js +3 -1
- package/index.js.map +1 -1
- package/package.json +1 -1
- package/presentation.d.ts +53 -0
- package/presentation.d.ts.map +1 -0
- package/presentation.js +156 -0
- package/presentation.js.map +1 -0
- package/src/__tests__/client-prepare.test.ts +3 -7
- package/src/__tests__/client-select-archive.test.ts +14 -12
- package/src/__tests__/detect.test.ts +11 -0
- package/src/__tests__/presentation.test.ts +186 -0
- package/src/client/builtin.ts +20 -39
- package/src/client/presentation.ts +47 -0
- package/src/client.ts +3 -0
- package/src/detect.ts +8 -0
- package/src/dialects/manifest.ts +37 -6
- package/src/dialects/open.ts +3 -2
- package/src/files.ts +7 -0
- package/src/index.ts +3 -1
- package/src/presentation.ts +170 -0
package/src/client/builtin.ts
CHANGED
|
@@ -1,60 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* catalogue
|
|
2
|
+
* The one source every client lists without being told: Stigmer's official
|
|
3
|
+
* catalogue.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* The catalogue is curated (`plugins/README.md`, "What the catalogue holds"):
|
|
6
|
+
* every entry is one Stigmer may ship and one whose servers Stigmer can
|
|
7
|
+
* connect to, vendored from a publisher's redistributable set or authored
|
|
8
|
+
* by Stigmer for a vendor's public endpoint. The vendors' own repositories
|
|
9
|
+
* were built in for one release and were taken out again when the audit
|
|
10
|
+
* behind the catalogue measured that more than half of their entries fail
|
|
11
|
+
* that bar: a chip that offers what the catalogue left out re-surfaces
|
|
12
|
+
* what curation removed. A user who wants another catalogue adds it as a
|
|
13
|
+
* source of his own (a repository with a marketplace file, read from
|
|
14
|
+
* GitHub), in the console's Manage sources or with `stigmer marketplace
|
|
15
|
+
* add`; the vendors' repositories are ordinary candidates for that, at the
|
|
16
|
+
* user's word, never on offer by default.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* truncated, all under the console's 20,000-entry listing cap; Codex's
|
|
20
|
-
* catalogue names its entries as `{source: "local", path}` objects, which
|
|
21
|
-
* the reader accepts, and three of its 65 as remote sources, which it
|
|
22
|
-
* drops with its own sentence.
|
|
18
|
+
* The list is code, not stored state: a client's remembered sources hold
|
|
19
|
+
* only what the user added, so the two clients cannot drift from each
|
|
20
|
+
* other. The official name is reserved the way it always was; a user can
|
|
21
|
+
* neither add over it nor remove it.
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
24
|
import type { GitHubMarketplaceSource } from "./refs.js";
|
|
26
25
|
import { OFFICIAL_MARKETPLACE_NAME } from "./refs.js";
|
|
27
26
|
|
|
28
|
-
/** A source a client ships with: named,
|
|
27
|
+
/** A source a client ships with: named, and either the official catalogue or a public GitHub tree. */
|
|
29
28
|
export interface BuiltInMarketplace {
|
|
30
29
|
readonly name: string;
|
|
31
|
-
/** One sentence for the Marketplace's section heading and `marketplace list`. */
|
|
32
|
-
readonly description: string;
|
|
33
30
|
readonly source: { readonly type: "official" } | GitHubMarketplaceSource;
|
|
34
31
|
}
|
|
35
32
|
|
|
36
|
-
/** In listing order
|
|
33
|
+
/** In listing order. Today one entry; the type keeps the shape a future built-in would take. */
|
|
37
34
|
export const BUILT_IN_MARKETPLACES: readonly BuiltInMarketplace[] = [
|
|
38
35
|
{
|
|
39
36
|
name: OFFICIAL_MARKETPLACE_NAME,
|
|
40
|
-
description: "Stigmer's official catalogue, published with each release.",
|
|
41
37
|
source: { type: "official" },
|
|
42
38
|
},
|
|
43
|
-
{
|
|
44
|
-
name: "cursor-plugins",
|
|
45
|
-
description: "Cursor's public plugin catalogue.",
|
|
46
|
-
source: { type: "github", repo: "cursor/plugins" },
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
name: "claude-code-plugins",
|
|
50
|
-
description: "Claude Code's public plugin catalogue.",
|
|
51
|
-
source: { type: "github", repo: "anthropics/claude-code" },
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
name: "codex-plugins",
|
|
55
|
-
description: "Codex's public plugin catalogue.",
|
|
56
|
-
source: { type: "github", repo: "openai/plugins" },
|
|
57
|
-
},
|
|
58
39
|
];
|
|
59
40
|
|
|
60
41
|
const BUILT_IN_NAMES: ReadonlySet<string> = new Set(BUILT_IN_MARKETPLACES.map((entry) => entry.name));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's presentation from a tree whose bytes cost something to obtain:
|
|
3
|
+
* the lazy twin of `readPluginPresentation`, as `preparePluginFromTree` is
|
|
4
|
+
* the lazy twin of the full read.
|
|
5
|
+
*
|
|
6
|
+
* A storefront lists a catalogue from one file and then shows a card per
|
|
7
|
+
* entry; the face on each card is in that entry's manifest, one small file
|
|
8
|
+
* in a directory the client has listed but not read. This reads exactly the
|
|
9
|
+
* manifest paths the listing shows (one, rarely two) and nothing else, then
|
|
10
|
+
* hands a `PluginFiles` whose `read` answers for those paths alone to the
|
|
11
|
+
* pure reader. A manifest that cannot be fetched contributes nothing, the
|
|
12
|
+
* pure reader's own rule for a manifest that cannot be parsed: a card is
|
|
13
|
+
* never refused, it is drawn with what arrived.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { comparePaths, type PluginFiles } from "../files.js";
|
|
17
|
+
import { MANIFEST_LOCATIONS } from "../messages.js";
|
|
18
|
+
import { type PluginPresentation, readPluginPresentation } from "../presentation.js";
|
|
19
|
+
import type { LazyCandidate } from "./prepare.js";
|
|
20
|
+
|
|
21
|
+
const MANIFEST_PATHS: ReadonlySet<string> = new Set(Object.values(MANIFEST_LOCATIONS));
|
|
22
|
+
|
|
23
|
+
/** The presentation of the plugin whose files `candidates` list, reading only its manifests. */
|
|
24
|
+
export async function readPluginPresentationFromTree(candidates: readonly LazyCandidate[]): Promise<PluginPresentation> {
|
|
25
|
+
const contents = new Map<string, Uint8Array>();
|
|
26
|
+
await Promise.all(
|
|
27
|
+
candidates
|
|
28
|
+
.filter((candidate) => MANIFEST_PATHS.has(candidate.path))
|
|
29
|
+
.map(async (candidate) => {
|
|
30
|
+
try {
|
|
31
|
+
contents.set(candidate.path, await candidate.read());
|
|
32
|
+
} catch {
|
|
33
|
+
// Unfetchable is unreadable: the pure reader skips a manifest it cannot open.
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
const files: PluginFiles = {
|
|
38
|
+
// `PluginFiles` promises a sorted listing; a host's tree order is its own.
|
|
39
|
+
entries: candidates.map(({ path, size }) => ({ path, size })).sort((a, b) => comparePaths(a.path, b.path)),
|
|
40
|
+
read: (path) => {
|
|
41
|
+
const bytes = contents.get(path);
|
|
42
|
+
if (bytes === undefined) throw new Error(`plugin file '${path}' was not fetched for presentation`);
|
|
43
|
+
return bytes;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
return readPluginPresentation(files);
|
|
47
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* the SHA-256 the server records;
|
|
18
18
|
* - the preparation (`prepare.ts`): select, read, refuse, archive, digest,
|
|
19
19
|
* once, for a tree on disk, on a host, or in a browser's memory;
|
|
20
|
+
* - the presentation (`presentation.ts`): a card's face from a listed
|
|
21
|
+
* tree, reading the entry's manifest and nothing else;
|
|
20
22
|
* - the grammars (`refs.ts`): an install ref and a GitHub marketplace
|
|
21
23
|
* source, with their refusal sentences;
|
|
22
24
|
* - the built-in sources (`builtin.ts`): the official catalogue and the
|
|
@@ -65,6 +67,7 @@ export {
|
|
|
65
67
|
preparePluginArchive,
|
|
66
68
|
preparePluginFromTree,
|
|
67
69
|
} from "./client/prepare.js";
|
|
70
|
+
export { readPluginPresentationFromTree } from "./client/presentation.js";
|
|
68
71
|
export {
|
|
69
72
|
type BuiltInMarketplace,
|
|
70
73
|
BUILT_IN_MARKETPLACES,
|
package/src/detect.ts
CHANGED
|
@@ -41,6 +41,14 @@ export const OPEN_MCP_CONFIG = "mcp.json";
|
|
|
41
41
|
/** The vendor precedence when no root manifest exists. */
|
|
42
42
|
const VENDOR_PRECEDENCE: readonly Exclude<PluginDialect, "agent-plugins">[] = ["claude", "cursor", "codex"];
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Every manifest location in the order that names a plugin: the root
|
|
46
|
+
* manifest, then the vendors. The one precedence the reader applies,
|
|
47
|
+
* exported so a partial read (`presentation.ts`) cannot rank the manifests
|
|
48
|
+
* differently from the full one.
|
|
49
|
+
*/
|
|
50
|
+
export const MANIFEST_PRECEDENCE: readonly PluginDialect[] = ["agent-plugins", ...VENDOR_PRECEDENCE];
|
|
51
|
+
|
|
44
52
|
const NAME_MAX_LENGTH = 64;
|
|
45
53
|
const NAME_PATTERN = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
|
46
54
|
|
package/src/dialects/manifest.ts
CHANGED
|
@@ -215,11 +215,36 @@ const EXPERIMENTAL_KINDS: Readonly<Record<string, IgnoredComponentKind>> = {
|
|
|
215
215
|
};
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
218
|
+
* Stigmer's own namespace under a root manifest's `extensions`. The open
|
|
219
|
+
* format has no appearance fields, so this is where an open-format plugin
|
|
220
|
+
* says what a storefront card shows: the same three the Cursor manifest
|
|
221
|
+
* carries at its top level, read by `presentation.ts` and treated by the
|
|
222
|
+
* install exactly as Cursor's are (`logo` an ignored component, because
|
|
223
|
+
* nothing in an Organization carries the image; `displayName` and
|
|
224
|
+
* `category` silent). The overlay FOLDER `ai.stigmer/` (`normalise/overlay.ts`)
|
|
225
|
+
* is the same name in a different place: resources there, appearance here.
|
|
226
|
+
*/
|
|
227
|
+
export const STIGMER_EXTENSION_NAMESPACE = "ai.stigmer";
|
|
228
|
+
export const STIGMER_EXTENSION_FIELDS: ReadonlySet<string> = new Set(["displayName", "logo", "category"]);
|
|
229
|
+
|
|
230
|
+
/** The `ai.stigmer` extension object of a root manifest, or `undefined` when absent or not an object. */
|
|
231
|
+
export function stigmerExtensionOf(object: JsonObject): JsonObject | undefined {
|
|
232
|
+
const extensions = object["extensions"];
|
|
233
|
+
if (!isJsonObject(extensions)) return undefined;
|
|
234
|
+
const content = extensions[STIGMER_EXTENSION_NAMESPACE];
|
|
235
|
+
return isJsonObject(content) ? content : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `extensions` in a root manifest: every foreign namespace with content is
|
|
240
|
+
* an ignored component (a client ignores namespaces it does not implement
|
|
241
|
+
* without validating them); an empty object is silent. Stigmer's own
|
|
242
|
+
* namespace is read field by field: `logo` is recorded as the `logo`
|
|
243
|
+
* component Cursor's would be, the other appearance fields are silent, and
|
|
244
|
+
* any field beyond `STIGMER_EXTENSION_FIELDS` makes the namespace an
|
|
245
|
+
* ignored `extension` component so a misspelled key stays visible. A
|
|
246
|
+
* non-object `extensions` is the open format's one non-fatal type
|
|
247
|
+
* violation: reported and ignored.
|
|
223
248
|
*/
|
|
224
249
|
export function extensionComponents(object: JsonObject, path: string, findings: Findings): IgnoredComponent[] {
|
|
225
250
|
const value = object["extensions"];
|
|
@@ -231,7 +256,13 @@ export function extensionComponents(object: JsonObject, path: string, findings:
|
|
|
231
256
|
const ignored: IgnoredComponent[] = [];
|
|
232
257
|
for (const [namespace, content] of fields(value)) {
|
|
233
258
|
if (isJsonObject(content) && fields(content).length === 0) continue;
|
|
234
|
-
|
|
259
|
+
const at = `${path}#extensions.${namespace}`;
|
|
260
|
+
if (namespace === STIGMER_EXTENSION_NAMESPACE && isJsonObject(content)) {
|
|
261
|
+
if (content["logo"] !== undefined) ignored.push({ kind: "logo", path: `${at}.logo` });
|
|
262
|
+
if (fields(content).some(([name]) => !STIGMER_EXTENSION_FIELDS.has(name))) ignored.push({ kind: "extension", path: at });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
ignored.push({ kind: "extension", path: at });
|
|
235
266
|
}
|
|
236
267
|
return ignored;
|
|
237
268
|
}
|
package/src/dialects/open.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* so this manifest declares no paths and no inline servers; `mcp.json` is
|
|
9
9
|
* read under the open format's rules by `detect.ts` when it exists.
|
|
10
10
|
* Client-specific data belongs under `extensions`, which Stigmer records as
|
|
11
|
-
* ignored per namespace
|
|
12
|
-
*
|
|
11
|
+
* ignored per foreign namespace; its own `ai.stigmer` namespace carries the
|
|
12
|
+
* appearance fields a storefront card shows (`manifest.ts`,
|
|
13
|
+
* `STIGMER_EXTENSION_FIELDS`), the open format's only place for them.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { describeValue, type JsonObject } from "../documents.js";
|
package/src/files.ts
CHANGED
|
@@ -58,6 +58,13 @@ export const PLUGIN_DOCUMENT_LIMITS = {
|
|
|
58
58
|
subAgent: 1024 * 1024,
|
|
59
59
|
/** A document under `ai.stigmer/`: a resource YAML. */
|
|
60
60
|
overlay: 1024 * 1024,
|
|
61
|
+
/**
|
|
62
|
+
* A plugin's logo, the image a storefront card shows. Checked from the
|
|
63
|
+
* listing's declared size before a client hands the file's URL to an
|
|
64
|
+
* `<img>`; measured across the three vendors' catalogues on 2026-09-18,
|
|
65
|
+
* the largest published logo is 361 KB and the median 7.5 KB.
|
|
66
|
+
*/
|
|
67
|
+
logo: 1024 * 1024,
|
|
61
68
|
/**
|
|
62
69
|
* A marketplace file in any dialect. The largest public catalogue
|
|
63
70
|
* (`cursor/plugins`, 79 entries with descriptions) is under 32 KB; the cap
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* `@stigmer/plugin-package`: the public surface.
|
|
3
3
|
*
|
|
4
4
|
* `readPluginPackage` reads one plugin; `readMarketplace` reads a catalogue
|
|
5
|
-
* of them
|
|
5
|
+
* of them; `readPluginPresentation` reads what a card shows for one entry
|
|
6
|
+
* without opening the rest of it. Everything else here is what a consumer needs to supply its
|
|
6
7
|
* input (`PluginFiles`, the caps), route a directory (`MANIFEST_LOCATIONS`,
|
|
7
8
|
* `hasPluginManifest`, `MARKETPLACE_LOCATIONS`, `hasMarketplaceFile`), or
|
|
8
9
|
* render an outcome (the finding kinds and `isErrorKind`). The dialect
|
|
@@ -56,6 +57,7 @@ export type {
|
|
|
56
57
|
} from "./marketplace/outcome.js";
|
|
57
58
|
export { hasMarketplaceFile, readMarketplace, readMarketplaceFile } from "./marketplace/read-marketplace.js";
|
|
58
59
|
export { readPluginPackage } from "./read-plugin-package.js";
|
|
60
|
+
export { type PluginPresentation, readPluginPresentation } from "./presentation.js";
|
|
59
61
|
export type {
|
|
60
62
|
IgnoredComponent,
|
|
61
63
|
IgnoredComponentKind,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a storefront shows for a plugin before anyone installs it: a display
|
|
3
|
+
* name, a logo, an author, a version, a description, a category.
|
|
4
|
+
*
|
|
5
|
+
* This is a read apart from `readPluginPackage` on purpose. The full read
|
|
6
|
+
* decides what Stigmer INSTALLS and refuses what it cannot; a card must
|
|
7
|
+
* render for every entry a catalogue lists, refused or not, and must cost
|
|
8
|
+
* one manifest, not the plugin's whole tree. So this reader opens only the
|
|
9
|
+
* manifests present, takes what they say about appearance, and never
|
|
10
|
+
* reports a finding: an unreadable manifest contributes nothing, a wrong
|
|
11
|
+
* type is treated as absent, and the caller draws a fallback face.
|
|
12
|
+
*
|
|
13
|
+
* Which manifest speaks is the full reader's rule (`MANIFEST_PRECEDENCE`:
|
|
14
|
+
* the root manifest, then Claude, Cursor, Codex), so a plugin shipping two
|
|
15
|
+
* dialects is named the same way on a card and in an install. Identity
|
|
16
|
+
* fields (`name`, `version`, `description`, `author`) come from the first
|
|
17
|
+
* manifest that carries each; the presentation fields come from the
|
|
18
|
+
* dialects that define them: Cursor's top-level `displayName`, `logo` and
|
|
19
|
+
* `category`; the legacy Codex manifest's `interface.displayName`,
|
|
20
|
+
* `interface.logo` and `interface.category`; the open format's
|
|
21
|
+
* `extensions["ai.stigmer"]` with the same three names, the one place that
|
|
22
|
+
* format leaves a client for them (Stigmer's authored catalogue plugins are
|
|
23
|
+
* its first users). Claude Code's manifest defines none.
|
|
24
|
+
*
|
|
25
|
+
* A logo is a path inside the plugin, and the card that shows it fetches it
|
|
26
|
+
* by URL from wherever the tree lives. So the path is kept only when it is
|
|
27
|
+
* contained, names an image by extension, is LISTED in the tree, and its
|
|
28
|
+
* declared size is under `PLUGIN_DOCUMENT_LIMITS.logo`; the library's rule
|
|
29
|
+
* that a cap is checked from declared sizes before a byte moves holds here
|
|
30
|
+
* too. Both spellings the vendors use (`assets/logo.png`, `./assets/logo.png`)
|
|
31
|
+
* are accepted, as the marketplace reader accepts both for a source.
|
|
32
|
+
*
|
|
33
|
+
* `logo` stays an ignored component in the full read: nothing in an
|
|
34
|
+
* Organization carries the image after an install, and the preview's "Not
|
|
35
|
+
* installed" line is true until the stored plugin does.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { MANIFEST_PRECEDENCE } from "./detect.js";
|
|
39
|
+
import { stigmerExtensionOf } from "./dialects/manifest.js";
|
|
40
|
+
import { decodeUtf8, isContainedPath, PLUGIN_DOCUMENT_LIMITS, PluginFileIndex, type PluginFiles } from "./files.js";
|
|
41
|
+
import { isJsonObject, type JsonObject } from "./documents.js";
|
|
42
|
+
import { MANIFEST_LOCATIONS } from "./messages.js";
|
|
43
|
+
import type { PluginAuthor, PluginDialect } from "./types.js";
|
|
44
|
+
|
|
45
|
+
/** What a card shows for a plugin; every field optional, because every dialect may omit it. */
|
|
46
|
+
export interface PluginPresentation {
|
|
47
|
+
/** The name for people (`Thermos`); the install name stays `name`. */
|
|
48
|
+
readonly displayName?: string;
|
|
49
|
+
/** A plugin-relative path to an image the tree lists, verified as described in the module header. */
|
|
50
|
+
readonly logo?: string;
|
|
51
|
+
readonly author?: PluginAuthor;
|
|
52
|
+
readonly version?: string;
|
|
53
|
+
readonly description?: string;
|
|
54
|
+
/** The catalogue's own grouping word (`developer-tools`, `Productivity`), as written. */
|
|
55
|
+
readonly category?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The image extensions a browser renders in an `<img>`; a logo named otherwise is not shown. */
|
|
59
|
+
const LOGO_EXTENSIONS: ReadonlySet<string> = new Set(["png", "svg", "jpg", "jpeg", "webp", "gif"]);
|
|
60
|
+
|
|
61
|
+
/** The appearance fields of every manifest present, merged in precedence order. */
|
|
62
|
+
export function readPluginPresentation(files: PluginFiles): PluginPresentation {
|
|
63
|
+
const index = new PluginFileIndex(files);
|
|
64
|
+
const presentation: { -readonly [K in keyof PluginPresentation]: PluginPresentation[K] } = {};
|
|
65
|
+
|
|
66
|
+
for (const dialect of MANIFEST_PRECEDENCE) {
|
|
67
|
+
const object = readManifest(index, MANIFEST_LOCATIONS[dialect]);
|
|
68
|
+
if (object === undefined) continue;
|
|
69
|
+
const contribution = contributionOf(dialect, object, index);
|
|
70
|
+
for (const key of Object.keys(contribution) as (keyof PluginPresentation)[]) {
|
|
71
|
+
if (presentation[key] === undefined) Object.assign(presentation, { [key]: contribution[key] });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return presentation;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The manifest at `path` as an object, or `undefined` for anything a card should not stumble on. */
|
|
79
|
+
function readManifest(index: PluginFileIndex, path: string): JsonObject | undefined {
|
|
80
|
+
const entry = index.entry(path);
|
|
81
|
+
if (entry === undefined || entry.size > PLUGIN_DOCUMENT_LIMITS.manifest) return undefined;
|
|
82
|
+
let bytes: Uint8Array;
|
|
83
|
+
try {
|
|
84
|
+
bytes = index.files.read(path);
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (bytes.length > PLUGIN_DOCUMENT_LIMITS.manifest) return undefined;
|
|
89
|
+
let value: unknown;
|
|
90
|
+
try {
|
|
91
|
+
value = JSON.parse(decodeUtf8(bytes));
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return isJsonObject(value) ? value : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function contributionOf(dialect: PluginDialect, object: JsonObject, index: PluginFileIndex): PluginPresentation {
|
|
99
|
+
const identity = identityOf(object);
|
|
100
|
+
switch (dialect) {
|
|
101
|
+
case "cursor":
|
|
102
|
+
return { ...identity, ...appearanceOf(object, index) };
|
|
103
|
+
case "codex": {
|
|
104
|
+
const surface = object["interface"];
|
|
105
|
+
return isJsonObject(surface) ? { ...identity, ...appearanceOf(surface, index) } : identity;
|
|
106
|
+
}
|
|
107
|
+
case "agent-plugins": {
|
|
108
|
+
const extension = stigmerExtensionOf(object);
|
|
109
|
+
return extension === undefined ? identity : { ...identity, ...appearanceOf(extension, index) };
|
|
110
|
+
}
|
|
111
|
+
case "claude":
|
|
112
|
+
return identity;
|
|
113
|
+
default: {
|
|
114
|
+
const exhaustive: never = dialect;
|
|
115
|
+
return exhaustive;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The identity fields every dialect shares, typed loosely: a wrong type is absent, never a finding. */
|
|
121
|
+
function identityOf(object: JsonObject): PluginPresentation {
|
|
122
|
+
const author = authorOf(object["author"]);
|
|
123
|
+
return {
|
|
124
|
+
...optional("version", object["version"]),
|
|
125
|
+
...optional("description", object["description"]),
|
|
126
|
+
...(author !== undefined && { author }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `displayName`, `logo` and `category` from an object that carries them at its top level (a Cursor manifest, a Codex `interface`, an open manifest's `extensions["ai.stigmer"]`). */
|
|
131
|
+
function appearanceOf(object: JsonObject, index: PluginFileIndex): PluginPresentation {
|
|
132
|
+
const logo = logoPath(object["logo"], index);
|
|
133
|
+
return {
|
|
134
|
+
...optional("displayName", object["displayName"]),
|
|
135
|
+
...optional("category", object["category"]),
|
|
136
|
+
...(logo !== undefined && { logo }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function authorOf(value: unknown): PluginAuthor | undefined {
|
|
141
|
+
if (!isJsonObject(value)) return undefined;
|
|
142
|
+
const author: { -readonly [K in keyof PluginAuthor]: PluginAuthor[K] } = {
|
|
143
|
+
...optional("name", value["name"]),
|
|
144
|
+
...optional("email", value["email"]),
|
|
145
|
+
...optional("url", value["url"]),
|
|
146
|
+
};
|
|
147
|
+
return Object.keys(author).length === 0 ? undefined : author;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A declared logo as a plugin-relative path the tree lists, or nothing.
|
|
152
|
+
* `./` is stripped (Codex's spelling); an absolute path, a `..` segment, a
|
|
153
|
+
* URL, a non-image extension, an unlisted file or an over-cap file all
|
|
154
|
+
* mean "no logo", never a refusal.
|
|
155
|
+
*/
|
|
156
|
+
function logoPath(value: unknown, index: PluginFileIndex): string | undefined {
|
|
157
|
+
if (typeof value !== "string") return undefined;
|
|
158
|
+
const path = value.startsWith("./") ? value.slice(2) : value;
|
|
159
|
+
if (!isContainedPath(path)) return undefined;
|
|
160
|
+
const dot = path.lastIndexOf(".");
|
|
161
|
+
if (dot === -1 || !LOGO_EXTENSIONS.has(path.slice(dot + 1).toLowerCase())) return undefined;
|
|
162
|
+
const entry = index.entry(path);
|
|
163
|
+
if (entry === undefined || entry.size > PLUGIN_DOCUMENT_LIMITS.logo) return undefined;
|
|
164
|
+
return path;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `{ [key]: value }` when `value` is a non-empty string; `{}` otherwise. */
|
|
168
|
+
function optional<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
|
|
169
|
+
return typeof value === "string" && value !== "" ? ({ [key]: value } as Record<K, string>) : {};
|
|
170
|
+
}
|