@stigmer/plugin-package 3.15.3-dev.20260916211208 → 3.16.1-dev.20260917224426
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/archive.d.ts +27 -0
- package/client/archive.d.ts.map +1 -0
- package/client/archive.js +38 -0
- package/client/archive.js.map +1 -0
- package/client/ignore/defaults.d.ts +2 -0
- package/client/ignore/defaults.d.ts.map +1 -0
- package/client/ignore/defaults.js +140 -0
- package/client/ignore/defaults.js.map +1 -0
- package/client/ignore/match.d.ts +3 -0
- package/client/ignore/match.d.ts.map +1 -0
- package/client/ignore/match.js +166 -0
- package/client/ignore/match.js.map +1 -0
- package/client/ignore/matcher.d.ts +50 -0
- package/client/ignore/matcher.d.ts.map +1 -0
- package/client/ignore/matcher.js +116 -0
- package/client/ignore/matcher.js.map +1 -0
- package/client/ignore/pattern.d.ts +13 -0
- package/client/ignore/pattern.d.ts.map +1 -0
- package/client/ignore/pattern.js +118 -0
- package/client/ignore/pattern.js.map +1 -0
- package/client/refs.d.ts +76 -0
- package/client/refs.d.ts.map +1 -0
- package/client/refs.js +123 -0
- package/client/refs.js.map +1 -0
- package/client/select.d.ts +74 -0
- package/client/select.d.ts.map +1 -0
- package/client/select.js +121 -0
- package/client/select.js.map +1 -0
- package/client/vocabulary.d.ts +13 -0
- package/client/vocabulary.d.ts.map +1 -0
- package/client/vocabulary.js +17 -0
- package/client/vocabulary.js.map +1 -0
- package/client.d.ts +34 -0
- package/client.d.ts.map +1 -0
- package/client.js +34 -0
- package/client.js.map +1 -0
- package/files.d.ts +6 -0
- package/files.d.ts.map +1 -1
- package/files.js +6 -0
- package/files.js.map +1 -1
- package/index.d.ts +11 -6
- package/index.d.ts.map +1 -1
- package/index.js +9 -5
- package/index.js.map +1 -1
- package/marketplace/messages.d.ts +20 -0
- package/marketplace/messages.d.ts.map +1 -0
- package/marketplace/messages.js +50 -0
- package/marketplace/messages.js.map +1 -0
- package/marketplace/outcome.d.ts +72 -0
- package/marketplace/outcome.d.ts.map +1 -0
- package/marketplace/outcome.js +29 -0
- package/marketplace/outcome.js.map +1 -0
- package/marketplace/read-marketplace.d.ts +34 -0
- package/marketplace/read-marketplace.d.ts.map +1 -0
- package/marketplace/read-marketplace.js +359 -0
- package/marketplace/read-marketplace.js.map +1 -0
- package/messages.d.ts +25 -9
- package/messages.d.ts.map +1 -1
- package/messages.js +24 -8
- package/messages.js.map +1 -1
- package/outcome.d.ts +13 -6
- package/outcome.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/__test-utils__/read.ts +4 -4
- package/src/__tests__/client-ignore.test.ts +133 -0
- package/src/__tests__/client-refs.test.ts +101 -0
- package/src/__tests__/client-select-archive.test.ts +152 -0
- package/src/__tests__/fixtures/cursor-plugins/.cursor-plugin/marketplace.json +412 -0
- package/src/__tests__/fixtures/cursor-plugins/NOTICE +5 -0
- package/src/__tests__/marketplace.test.ts +373 -0
- package/src/client/archive.ts +44 -0
- package/src/client/ignore/defaults.ts +155 -0
- package/src/client/ignore/match.ts +158 -0
- package/src/client/ignore/matcher.ts +156 -0
- package/src/client/ignore/pattern.ts +131 -0
- package/src/client/refs.ts +170 -0
- package/src/client/select.ts +161 -0
- package/src/client/vocabulary.ts +19 -0
- package/src/client.ts +67 -0
- package/src/files.ts +6 -0
- package/src/index.ts +21 -5
- package/src/marketplace/messages.ts +62 -0
- package/src/marketplace/outcome.ts +95 -0
- package/src/marketplace/read-marketplace.ts +402 -0
- package/src/messages.ts +34 -21
- package/src/outcome.ts +14 -6
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marketplace entry: a `PluginFiles` over a marketplace tree in, a
|
|
3
|
+
* `MarketplaceReadOutcome` out.
|
|
4
|
+
*
|
|
5
|
+
* The read is a fixed sequence, like the plugin read: locate the marketplace
|
|
6
|
+
* file (first present in the dialect precedence, and only that one is read;
|
|
7
|
+
* a tree that carries two dialects' files describes one catalogue twice);
|
|
8
|
+
* parse it under the marketplace cap; read the dialect's shape into the one
|
|
9
|
+
* normalised shape; validate names and sources; drop the entries the tree
|
|
10
|
+
* cannot install, each with its own warning; resolve the defaults. Findings
|
|
11
|
+
* are collected and the read continues, so the outcome names every problem
|
|
12
|
+
* at once, and the marketplace is returned only when nothing refused.
|
|
13
|
+
*
|
|
14
|
+
* Sources. Every dialect names a plugin's directory relative to the
|
|
15
|
+
* marketplace root (Claude and Codex with a `./` prefix, Cursor without,
|
|
16
|
+
* Claude optionally under `metadata.pluginRoot`); the reader accepts both
|
|
17
|
+
* spellings and refuses a path that escapes the root. A source that is not
|
|
18
|
+
* such a directory (Claude's `github`, `url`, `npm` and `git-subdir` objects,
|
|
19
|
+
* Codex's non-`local` sources, Cursor's remote URLs) is a form this reader
|
|
20
|
+
* does not fetch: the entry is dropped with `entry-source-unsupported`, and
|
|
21
|
+
* nothing is invented in its place.
|
|
22
|
+
*
|
|
23
|
+
* This reader does not open the plugins it lists. A consumer that wants an
|
|
24
|
+
* entry's own description or version reads that directory with
|
|
25
|
+
* `readPluginPackage`; a consumer that wants to install it hands the same
|
|
26
|
+
* directory to the walker it already uses. Keeping the two reads apart is
|
|
27
|
+
* what lets a catalogue of eighty entries be listed without eighty reads.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { hasPluginManifest, isValidPluginName } from "../detect.js";
|
|
31
|
+
import { describeValue, isJsonObject, isStringArray, type JsonObject } from "../documents.js";
|
|
32
|
+
import { decodeUtf8, isContainedPath, PLUGIN_DOCUMENT_LIMITS, PluginFileIndex, type PluginFiles } from "../files.js";
|
|
33
|
+
import { MARKETPLACE_LOCATIONS, MarketplaceFindings } from "./messages.js";
|
|
34
|
+
import type { Marketplace, MarketplaceDialect, MarketplaceEntry, MarketplaceOwner, MarketplaceReadOutcome } from "./outcome.js";
|
|
35
|
+
|
|
36
|
+
/** The precedence the reader applies when a tree carries more than one marketplace file. */
|
|
37
|
+
const DIALECT_PRECEDENCE: readonly MarketplaceDialect[] = ["stigmer", "claude", "cursor", "codex"];
|
|
38
|
+
|
|
39
|
+
/** Codex's per-entry install policy that marks a default. */
|
|
40
|
+
const CODEX_INSTALLED_BY_DEFAULT = "INSTALLED_BY_DEFAULT";
|
|
41
|
+
|
|
42
|
+
/** True when the tree holds any of the four marketplace files; the CLI's routing test. */
|
|
43
|
+
export function hasMarketplaceFile(paths: Iterable<string>): boolean {
|
|
44
|
+
const locations = new Set<string>(Object.values(MARKETPLACE_LOCATIONS));
|
|
45
|
+
for (const path of paths) if (locations.has(path)) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readMarketplace(files: PluginFiles): MarketplaceReadOutcome {
|
|
50
|
+
const findings = new MarketplaceFindings();
|
|
51
|
+
const index = new PluginFileIndex(files);
|
|
52
|
+
|
|
53
|
+
const located = locate(index);
|
|
54
|
+
if (located === undefined) {
|
|
55
|
+
findings.error("marketplace-not-found");
|
|
56
|
+
return refused(findings);
|
|
57
|
+
}
|
|
58
|
+
const { dialect, path } = located;
|
|
59
|
+
|
|
60
|
+
const object = readObject(index, path, findings);
|
|
61
|
+
if (object === undefined) return refused(findings);
|
|
62
|
+
|
|
63
|
+
const name = readName(object, path, findings);
|
|
64
|
+
const raw = readEntries(object, dialect, path, findings);
|
|
65
|
+
const entries = offeredEntries(raw, index, path, findings);
|
|
66
|
+
const defaults = resolveDefaults(object, raw, entries, dialect, path, findings);
|
|
67
|
+
|
|
68
|
+
if (findings.errors.length > 0 || name === undefined) return refused(findings);
|
|
69
|
+
|
|
70
|
+
const description = readDescription(object, dialect);
|
|
71
|
+
const owner = readOwner(object);
|
|
72
|
+
const marketplace: Marketplace = {
|
|
73
|
+
name,
|
|
74
|
+
...(description !== undefined && { description }),
|
|
75
|
+
...(owner !== undefined && { owner }),
|
|
76
|
+
dialect,
|
|
77
|
+
path,
|
|
78
|
+
plugins: entries,
|
|
79
|
+
defaults,
|
|
80
|
+
};
|
|
81
|
+
return { ok: true, marketplace, warnings: findings.warnings };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function refused(findings: MarketplaceFindings): MarketplaceReadOutcome {
|
|
85
|
+
return { ok: false, errors: findings.errors, warnings: findings.warnings };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function locate(index: PluginFileIndex): { dialect: MarketplaceDialect; path: string } | undefined {
|
|
89
|
+
for (const dialect of DIALECT_PRECEDENCE) {
|
|
90
|
+
const path = MARKETPLACE_LOCATIONS[dialect];
|
|
91
|
+
if (index.has(path)) return { dialect, path };
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readObject(index: PluginFileIndex, path: string, findings: MarketplaceFindings): JsonObject | undefined {
|
|
97
|
+
const limit = PLUGIN_DOCUMENT_LIMITS.marketplace;
|
|
98
|
+
const entry = index.entry(path);
|
|
99
|
+
if (entry === undefined) {
|
|
100
|
+
throw new Error(`marketplace file '${path}' is not listed`);
|
|
101
|
+
}
|
|
102
|
+
if (entry.size > limit) {
|
|
103
|
+
findings.error("marketplace-too-large", { path, subject: String(entry.size), detail: String(limit) });
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const bytes = index.files.read(path);
|
|
107
|
+
if (bytes.length > limit) {
|
|
108
|
+
findings.error("marketplace-too-large", { path, subject: String(bytes.length), detail: String(limit) });
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
let value: unknown;
|
|
112
|
+
try {
|
|
113
|
+
value = JSON.parse(decodeUtf8(bytes));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
findings.error("marketplace-unreadable", { path, detail: error instanceof Error ? error.message : String(error) });
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (!isJsonObject(value)) {
|
|
119
|
+
findings.error("marketplace-unreadable", { path, detail: "the document is not a JSON object" });
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The marketplace's own name, validated under the plugin name rule (the same characters an install ref can carry). */
|
|
126
|
+
function readName(object: JsonObject, path: string, findings: MarketplaceFindings): string | undefined {
|
|
127
|
+
const value = object["name"];
|
|
128
|
+
if (value === undefined) {
|
|
129
|
+
findings.error("marketplace-name-missing", { path });
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (typeof value !== "string") {
|
|
133
|
+
findings.error("marketplace-field-type", { path, subject: "name", detail: "a string" });
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (!isValidPluginName(value)) {
|
|
137
|
+
findings.error("marketplace-name-invalid", { path, subject: value });
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readDescription(object: JsonObject, dialect: MarketplaceDialect): string | undefined {
|
|
144
|
+
switch (dialect) {
|
|
145
|
+
case "stigmer":
|
|
146
|
+
return optionalStringOf(object, "description");
|
|
147
|
+
case "claude":
|
|
148
|
+
case "cursor": {
|
|
149
|
+
const metadata = object["metadata"];
|
|
150
|
+
return isJsonObject(metadata) ? optionalStringOf(metadata, "description") : undefined;
|
|
151
|
+
}
|
|
152
|
+
case "codex":
|
|
153
|
+
// Codex's `interface.displayName` is a label for a picker, not a description.
|
|
154
|
+
return undefined;
|
|
155
|
+
default: {
|
|
156
|
+
const exhaustive: never = dialect;
|
|
157
|
+
return exhaustive;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readOwner(object: JsonObject): MarketplaceOwner | undefined {
|
|
163
|
+
const value = object["owner"];
|
|
164
|
+
if (!isJsonObject(value)) return undefined;
|
|
165
|
+
const name = optionalStringOf(value, "name");
|
|
166
|
+
const email = optionalStringOf(value, "email");
|
|
167
|
+
const url = optionalStringOf(value, "url");
|
|
168
|
+
if (name === undefined && email === undefined && url === undefined) return undefined;
|
|
169
|
+
return {
|
|
170
|
+
...(name !== undefined && { name }),
|
|
171
|
+
...(email !== undefined && { email }),
|
|
172
|
+
...(url !== undefined && { url }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A string field or `undefined`; a field of another type is treated as absent (metadata never refuses a catalogue). */
|
|
177
|
+
function optionalStringOf(object: JsonObject, field: string): string | undefined {
|
|
178
|
+
const value = object[field];
|
|
179
|
+
return typeof value === "string" ? value : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One entry as the file states it, before the tree is consulted. `dir` is
|
|
184
|
+
* the resolved marketplace-relative directory, or `undefined` when the
|
|
185
|
+
* source is a form this reader does not fetch (the warning is recorded here
|
|
186
|
+
* so the entry's order in the file is kept for the sentence).
|
|
187
|
+
*/
|
|
188
|
+
interface RawEntry {
|
|
189
|
+
readonly name: string;
|
|
190
|
+
readonly dir: string | undefined;
|
|
191
|
+
readonly description?: string;
|
|
192
|
+
readonly installedByDefault: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readEntries(object: JsonObject, dialect: MarketplaceDialect, path: string, findings: MarketplaceFindings): readonly RawEntry[] {
|
|
196
|
+
const list = object["plugins"];
|
|
197
|
+
if (list === undefined) {
|
|
198
|
+
findings.error("marketplace-plugins-missing", { path });
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(list)) {
|
|
202
|
+
findings.error("marketplace-field-type", { path, subject: "plugins", detail: "an array" });
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const pluginRoot = dialect === "claude" ? claudePluginRoot(object) : "";
|
|
206
|
+
|
|
207
|
+
const entries: RawEntry[] = [];
|
|
208
|
+
const seen = new Set<string>();
|
|
209
|
+
list.forEach((item: unknown, position: number) => {
|
|
210
|
+
if (!isJsonObject(item)) {
|
|
211
|
+
findings.error("entry-shape", { path, subject: String(position) });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const name = item["name"];
|
|
215
|
+
if (name === undefined) {
|
|
216
|
+
findings.error("entry-name-missing", { path, subject: String(position) });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (typeof name !== "string" || !isValidPluginName(name)) {
|
|
220
|
+
findings.error("entry-name-invalid", { path, subject: describeValue(name) });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (seen.has(name)) {
|
|
224
|
+
findings.error("entry-name-duplicate", { path, subject: name });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
seen.add(name);
|
|
228
|
+
|
|
229
|
+
const description = optionalStringOf(item, "description");
|
|
230
|
+
entries.push({
|
|
231
|
+
name,
|
|
232
|
+
dir: resolveSource(item["source"], name, pluginRoot, path, findings),
|
|
233
|
+
...(description !== undefined && { description }),
|
|
234
|
+
installedByDefault: dialect === "codex" && codexInstalledByDefault(item),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
return entries;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Claude's `metadata.pluginRoot`, prepended to every relative source; the empty string when absent or unusable. */
|
|
241
|
+
function claudePluginRoot(object: JsonObject): string {
|
|
242
|
+
const metadata = object["metadata"];
|
|
243
|
+
if (!isJsonObject(metadata)) return "";
|
|
244
|
+
const root = optionalStringOf(metadata, "pluginRoot");
|
|
245
|
+
if (root === undefined) return "";
|
|
246
|
+
const resolved = relativeDirectory(root);
|
|
247
|
+
return resolved.ok ? resolved.dir : "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function codexInstalledByDefault(item: JsonObject): boolean {
|
|
251
|
+
const policy = item["policy"];
|
|
252
|
+
return isJsonObject(policy) && policy["installation"] === CODEX_INSTALLED_BY_DEFAULT;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A source becomes a marketplace-relative directory or nothing. A string is
|
|
257
|
+
* a relative directory (with or without `./`) unless it is a URL; Codex's
|
|
258
|
+
* `{source: "local", path}` object is the same directory; every other
|
|
259
|
+
* object form is a remote this reader does not fetch.
|
|
260
|
+
*/
|
|
261
|
+
function resolveSource(
|
|
262
|
+
source: unknown,
|
|
263
|
+
entryName: string,
|
|
264
|
+
pluginRoot: string,
|
|
265
|
+
path: string,
|
|
266
|
+
findings: MarketplaceFindings,
|
|
267
|
+
): string | undefined {
|
|
268
|
+
if (source === undefined) {
|
|
269
|
+
findings.error("entry-source-missing", { path, subject: entryName });
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
let candidate: string | undefined;
|
|
273
|
+
if (typeof source === "string") {
|
|
274
|
+
if (isUrl(source)) {
|
|
275
|
+
findings.warn("entry-source-unsupported", { path, subject: entryName, detail: source });
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
candidate = source;
|
|
279
|
+
} else if (isJsonObject(source) && source["source"] === "local" && typeof source["path"] === "string") {
|
|
280
|
+
candidate = source["path"];
|
|
281
|
+
} else {
|
|
282
|
+
findings.warn("entry-source-unsupported", { path, subject: entryName, detail: describeSource(source) });
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const resolved = relativeDirectory(candidate);
|
|
287
|
+
if (!resolved.ok) {
|
|
288
|
+
findings.error("entry-source-escapes-root", { path, subject: entryName, detail: candidate });
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
return joinDirectories(pluginRoot, resolved.dir);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The `source` discriminator of a remote object, or the whole value, for the sentence. */
|
|
295
|
+
function describeSource(source: unknown): string {
|
|
296
|
+
if (isJsonObject(source) && typeof source["source"] === "string") return source["source"];
|
|
297
|
+
return describeValue(source);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isUrl(value: string): boolean {
|
|
301
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type RelativeDirectory = { readonly ok: true; readonly dir: string } | { readonly ok: false };
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `./thermos`, `thermos`, `third_party/github/`, `.` and `./` all name a
|
|
308
|
+
* directory inside the root (the root itself is the empty string); an
|
|
309
|
+
* absolute path, a backslash, or a `..` segment does not.
|
|
310
|
+
*/
|
|
311
|
+
function relativeDirectory(value: string): RelativeDirectory {
|
|
312
|
+
let trimmed = value;
|
|
313
|
+
if (trimmed === "." || trimmed === "./") return { ok: true, dir: "" };
|
|
314
|
+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
315
|
+
trimmed = trimmed.replace(/\/+$/, "");
|
|
316
|
+
if (trimmed === "") return { ok: true, dir: "" };
|
|
317
|
+
return isContainedPath(trimmed) ? { ok: true, dir: trimmed } : { ok: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function joinDirectories(root: string, dir: string): string {
|
|
321
|
+
if (root === "") return dir;
|
|
322
|
+
return dir === "" ? root : `${root}/${dir}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The entries the tree can install: a directory that exists and holds a plugin manifest at its root. */
|
|
326
|
+
function offeredEntries(
|
|
327
|
+
raw: readonly RawEntry[],
|
|
328
|
+
index: PluginFileIndex,
|
|
329
|
+
path: string,
|
|
330
|
+
findings: MarketplaceFindings,
|
|
331
|
+
): readonly MarketplaceEntry[] {
|
|
332
|
+
const offered: MarketplaceEntry[] = [];
|
|
333
|
+
for (const entry of raw) {
|
|
334
|
+
if (entry.dir === undefined) continue;
|
|
335
|
+
if (!index.isDirectory(entry.dir)) {
|
|
336
|
+
findings.warn("entry-directory-missing", { path, subject: entry.name, detail: entry.dir === "" ? "." : entry.dir });
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (!hasPluginManifest(childFilesOf(index, entry.dir))) {
|
|
340
|
+
findings.warn("entry-not-a-plugin", { path, subject: entry.name, detail: entry.dir === "" ? "." : entry.dir });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
offered.push({
|
|
344
|
+
name: entry.name,
|
|
345
|
+
dir: entry.dir,
|
|
346
|
+
...(entry.description !== undefined && { description: entry.description }),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return offered;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The files under `dir` as plugin-relative paths, so `hasPluginManifest` reads them as it reads a plugin's own listing. */
|
|
353
|
+
function childFilesOf(index: PluginFileIndex, dir: string): readonly string[] {
|
|
354
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
355
|
+
return index.filesUnder(dir).map((file) => file.slice(prefix.length));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The defaults, in install order: Stigmer's `defaults` list, or Codex's
|
|
360
|
+
* `INSTALLED_BY_DEFAULT` entries in file order. A default that names no
|
|
361
|
+
* listed entry refuses the file; a default whose entry the tree could not
|
|
362
|
+
* offer is dropped silently, because that entry's own warning already says
|
|
363
|
+
* why it is missing.
|
|
364
|
+
*/
|
|
365
|
+
function resolveDefaults(
|
|
366
|
+
object: JsonObject,
|
|
367
|
+
raw: readonly RawEntry[],
|
|
368
|
+
offered: readonly MarketplaceEntry[],
|
|
369
|
+
dialect: MarketplaceDialect,
|
|
370
|
+
path: string,
|
|
371
|
+
findings: MarketplaceFindings,
|
|
372
|
+
): readonly string[] {
|
|
373
|
+
const listed = new Set(raw.map((entry) => entry.name));
|
|
374
|
+
const offeredNames = new Set(offered.map((entry) => entry.name));
|
|
375
|
+
|
|
376
|
+
let named: readonly string[];
|
|
377
|
+
if (dialect === "stigmer") {
|
|
378
|
+
const value = object["defaults"];
|
|
379
|
+
if (value === undefined) {
|
|
380
|
+
named = [];
|
|
381
|
+
} else if (!isStringArray(value)) {
|
|
382
|
+
findings.error("marketplace-field-type", { path, subject: "defaults", detail: "an array of strings" });
|
|
383
|
+
named = [];
|
|
384
|
+
} else {
|
|
385
|
+
named = value;
|
|
386
|
+
}
|
|
387
|
+
} else if (dialect === "codex") {
|
|
388
|
+
named = raw.filter((entry) => entry.installedByDefault).map((entry) => entry.name);
|
|
389
|
+
} else {
|
|
390
|
+
named = [];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const defaults: string[] = [];
|
|
394
|
+
for (const name of named) {
|
|
395
|
+
if (!listed.has(name)) {
|
|
396
|
+
findings.error("default-unknown", { path, subject: name });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (offeredNames.has(name) && !defaults.includes(name)) defaults.push(name);
|
|
400
|
+
}
|
|
401
|
+
return defaults;
|
|
402
|
+
}
|
package/src/messages.ts
CHANGED
|
@@ -11,13 +11,7 @@
|
|
|
11
11
|
* kind unions, so a kind without a sentence does not compile.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type {
|
|
15
|
-
FindingContext,
|
|
16
|
-
PluginErrorKind,
|
|
17
|
-
PluginFinding,
|
|
18
|
-
PluginFindingKind,
|
|
19
|
-
PluginWarningKind,
|
|
20
|
-
} from "./outcome.js";
|
|
14
|
+
import type { Finding, FindingContext, PluginErrorKind, PluginFindingKind, PluginWarningKind } from "./outcome.js";
|
|
21
15
|
|
|
22
16
|
/** The canonical `$schema` identifiers the open format pins per version. */
|
|
23
17
|
export const AGENT_PLUGINS_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
@@ -31,10 +25,12 @@ export const MANIFEST_LOCATIONS = {
|
|
|
31
25
|
codex: ".codex-plugin/plugin.json",
|
|
32
26
|
} as const;
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
const
|
|
28
|
+
/** Single-quote an identifier for a sentence (the ts-server quoting rule); shared with the marketplace vocabulary. */
|
|
29
|
+
export const q = (value: string | undefined): string => `'${value ?? ""}'`;
|
|
30
|
+
/** ` in '<path>'`, or nothing when the finding names no file. */
|
|
31
|
+
export const at = (path: string | undefined): string => (path === undefined ? "" : ` in ${q(path)}`);
|
|
36
32
|
|
|
37
|
-
type Sentence = (ctx: FindingContext) => string;
|
|
33
|
+
export type Sentence = (ctx: FindingContext) => string;
|
|
38
34
|
|
|
39
35
|
const ERROR_MESSAGES: Record<PluginErrorKind, Sentence> = {
|
|
40
36
|
"no-manifest": () =>
|
|
@@ -175,27 +171,44 @@ export function isErrorKind(kind: PluginFindingKind): kind is PluginErrorKind {
|
|
|
175
171
|
return kind in ERROR_MESSAGES;
|
|
176
172
|
}
|
|
177
173
|
|
|
174
|
+
/** A sentence table over a closed kind vocabulary; `Record` so a kind without a sentence does not compile. */
|
|
175
|
+
export type SentenceTable<K extends string> = Readonly<Record<K, Sentence>>;
|
|
176
|
+
|
|
178
177
|
/**
|
|
179
|
-
* Collects findings while a read proceeds.
|
|
180
|
-
*
|
|
181
|
-
* continues so the outcome carries them all.
|
|
178
|
+
* Collects findings while a read proceeds. A reader never throws on the
|
|
179
|
+
* content it reads: every problem becomes a finding here, and the read
|
|
180
|
+
* continues so the outcome carries them all. Generic over the two kind
|
|
181
|
+
* vocabularies so the plugin reader and the marketplace reader share one
|
|
182
|
+
* collector and one finding shape while each owns its sentences.
|
|
182
183
|
*/
|
|
183
|
-
export class
|
|
184
|
-
readonly errors:
|
|
185
|
-
readonly warnings:
|
|
184
|
+
export class FindingCollector<E extends string, W extends string> {
|
|
185
|
+
readonly errors: Finding<E>[] = [];
|
|
186
|
+
readonly warnings: Finding<W>[] = [];
|
|
187
|
+
|
|
188
|
+
constructor(
|
|
189
|
+
private readonly errorSentences: SentenceTable<E>,
|
|
190
|
+
private readonly warningSentences: SentenceTable<W>,
|
|
191
|
+
) {}
|
|
186
192
|
|
|
187
|
-
error(kind:
|
|
188
|
-
this.errors.push(compose(kind, ctx,
|
|
193
|
+
error(kind: E, ctx: FindingContext = {}): void {
|
|
194
|
+
this.errors.push(compose(kind, ctx, this.errorSentences[kind](ctx)));
|
|
189
195
|
}
|
|
190
196
|
|
|
191
|
-
warn(kind:
|
|
192
|
-
this.warnings.push(compose(kind, ctx,
|
|
197
|
+
warn(kind: W, ctx: FindingContext = {}): void {
|
|
198
|
+
this.warnings.push(compose(kind, ctx, this.warningSentences[kind](ctx)));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The plugin reader's collector, bound to the plugin vocabulary. */
|
|
203
|
+
export class Findings extends FindingCollector<PluginErrorKind, PluginWarningKind> {
|
|
204
|
+
constructor() {
|
|
205
|
+
super(ERROR_MESSAGES, WARNING_MESSAGES);
|
|
193
206
|
}
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
// Optional fields are omitted rather than set to `undefined` so a finding
|
|
197
210
|
// serialises to JSON without `null`s and compares structurally in tests.
|
|
198
|
-
function compose(kind:
|
|
211
|
+
function compose<K extends string>(kind: K, ctx: FindingContext, message: string): Finding<K> {
|
|
199
212
|
return {
|
|
200
213
|
kind,
|
|
201
214
|
...(ctx.path !== undefined && { path: ctx.path }),
|
package/src/outcome.ts
CHANGED
|
@@ -102,20 +102,28 @@ export type PluginWarningKind =
|
|
|
102
102
|
|
|
103
103
|
export type PluginFindingKind = PluginErrorKind | PluginWarningKind;
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
/**
|
|
106
|
+
* One finding over any closed kind vocabulary. The plugin reader and the
|
|
107
|
+
* marketplace reader (`marketplace/`) each own a vocabulary and a sentence
|
|
108
|
+
* table; the shape of a finding, and the renderer that prints one, are
|
|
109
|
+
* shared through this type.
|
|
110
|
+
*/
|
|
111
|
+
export interface Finding<K extends string> {
|
|
112
|
+
readonly kind: K;
|
|
107
113
|
/** The file, or the declared path, the finding is about. */
|
|
108
114
|
readonly path?: string;
|
|
109
|
-
/** The skill, server, sub-agent, variable or field the finding names. */
|
|
115
|
+
/** The skill, server, sub-agent, variable, entry or field the finding names. */
|
|
110
116
|
readonly subject?: string;
|
|
111
117
|
/** A second identifier the sentence needs (a field name, a parser's own message). */
|
|
112
118
|
readonly detail?: string;
|
|
113
|
-
/** The one sentence for this kind, from `messages.ts`. */
|
|
119
|
+
/** The one sentence for this kind, from the vocabulary's `messages.ts`. */
|
|
114
120
|
readonly message: string;
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
export type PluginFinding = Finding<PluginFindingKind>;
|
|
124
|
+
|
|
125
|
+
/** What a reader needs to compose a finding: everything but the sentence. */
|
|
126
|
+
export type FindingContext = Pick<Finding<string>, "path" | "subject" | "detail">;
|
|
119
127
|
|
|
120
128
|
export type PluginReadOutcome =
|
|
121
129
|
| { readonly ok: true; readonly plugin: PluginPackage; readonly warnings: readonly PluginFinding[] }
|