@stigmer/plugin-package 3.16.0 → 3.17.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/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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files of a tree a plugin push carries, and in what order.
|
|
3
|
+
*
|
|
4
|
+
* A client that installs a plugin holds a tree: the CLI a directory on disk,
|
|
5
|
+
* the console the file list a marketplace host returned. Both must choose
|
|
6
|
+
* the same files in the same order, because the archive's bytes are the
|
|
7
|
+
* plugin's identity on the server (`status.digest` is the SHA-256 of what
|
|
8
|
+
* it received) and the order of the archive's entries is part of those
|
|
9
|
+
* bytes. This module is that choice, made once.
|
|
10
|
+
*
|
|
11
|
+
* The rule is the CLI's original walk (`stigmer push plugin <dir>`): at each
|
|
12
|
+
* directory the names are sorted by code point, files and directories
|
|
13
|
+
* interleaved by name, and a directory the matcher ignores is skipped whole,
|
|
14
|
+
* so no negation inside it can pull a file back. Symlinks never appear (the
|
|
15
|
+
* CLI walker skips them; a hosted tree has none the client asks for).
|
|
16
|
+
* `compareWalkOrder` states that order for a flat list of paths so a client
|
|
17
|
+
* with no directory to walk arrives at the same sequence; it is NOT the plain
|
|
18
|
+
* code-point order of full paths (`b/x.txt` sorts before `b.txt` here,
|
|
19
|
+
* because the directory `b` sorts before the file `b.txt` at their level).
|
|
20
|
+
* The reader in `read-plugin-package.ts` indexes entries by path and is
|
|
21
|
+
* indifferent to their order; only the archive cares.
|
|
22
|
+
*
|
|
23
|
+
* `.gitignore` and `.stigmerignore` are read from the root of the tree being
|
|
24
|
+
* selected, as the CLI does, never from an ancestor: a marketplace entry is
|
|
25
|
+
* its own plugin root.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { PluginFileEntry, PluginFiles } from "../files.js";
|
|
29
|
+
import { type IgnoreSources, type Matcher, SOURCE_GITIGNORE, SOURCE_STIGMERIGNORE, buildMatcher } from "./ignore/matcher.js";
|
|
30
|
+
|
|
31
|
+
/** The tree a client holds before selection: every file it could offer, with its size. */
|
|
32
|
+
export interface CandidateFile {
|
|
33
|
+
/** Root-relative POSIX path, no leading `./`. */
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly size: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the selection included and left out, for the summary line every client prints. */
|
|
39
|
+
export interface SelectionStats {
|
|
40
|
+
filesIncluded: number;
|
|
41
|
+
filesIgnored: number;
|
|
42
|
+
/** Ignored directories that held at least one candidate; a tree lists no empty directories. */
|
|
43
|
+
dirsSkipped: number;
|
|
44
|
+
totalSize: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SelectPluginFilesOptions {
|
|
48
|
+
/** Whether the tree's own root `.gitignore` applies (every push says yes unless asked otherwise). */
|
|
49
|
+
readonly respectGitignore: boolean;
|
|
50
|
+
readonly extraIgnore?: readonly string[];
|
|
51
|
+
readonly extraInclude?: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PluginSelection {
|
|
55
|
+
readonly files: PluginFiles;
|
|
56
|
+
readonly stats: SelectionStats;
|
|
57
|
+
/** The matcher the selection ran, for diagnostics (`push --dry-run` lists its patterns). */
|
|
58
|
+
readonly matcher: Matcher;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The two files at a tree's root that shape its own selection. */
|
|
62
|
+
export const IGNORE_FILE_NAMES = {
|
|
63
|
+
gitignore: SOURCE_GITIGNORE,
|
|
64
|
+
stigmerignore: SOURCE_STIGMERIGNORE,
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Select the files a push of `candidates` carries. `read` yields a listed
|
|
69
|
+
* candidate's bytes and is called here only for the two ignore files at the
|
|
70
|
+
* root, so a client may hand over a lazy reader and pay for the rest only
|
|
71
|
+
* when the archive is built.
|
|
72
|
+
*/
|
|
73
|
+
export function selectPluginFiles(
|
|
74
|
+
candidates: readonly CandidateFile[],
|
|
75
|
+
read: (path: string) => Uint8Array,
|
|
76
|
+
options: SelectPluginFilesOptions,
|
|
77
|
+
): PluginSelection {
|
|
78
|
+
const byPath = new Map(candidates.map((candidate) => [candidate.path, candidate]));
|
|
79
|
+
const sources: IgnoreSources = {
|
|
80
|
+
includeDefaults: true,
|
|
81
|
+
...(options.respectGitignore &&
|
|
82
|
+
byPath.has(IGNORE_FILE_NAMES.gitignore) && { gitignore: decode(read(IGNORE_FILE_NAMES.gitignore)) }),
|
|
83
|
+
...(byPath.has(IGNORE_FILE_NAMES.stigmerignore) && {
|
|
84
|
+
stigmerignore: decode(read(IGNORE_FILE_NAMES.stigmerignore)),
|
|
85
|
+
}),
|
|
86
|
+
...(options.extraIgnore !== undefined && { extraIgnore: options.extraIgnore }),
|
|
87
|
+
...(options.extraInclude !== undefined && { extraInclude: options.extraInclude }),
|
|
88
|
+
};
|
|
89
|
+
const matcher = buildMatcher(sources);
|
|
90
|
+
|
|
91
|
+
const stats: SelectionStats = { filesIncluded: 0, filesIgnored: 0, dirsSkipped: 0, totalSize: 0 };
|
|
92
|
+
const skippedDirs = new Set<string>();
|
|
93
|
+
const entries: PluginFileEntry[] = [];
|
|
94
|
+
|
|
95
|
+
for (const candidate of [...candidates].sort((a, b) => compareWalkOrder(a.path, b.path))) {
|
|
96
|
+
if (underSkippedDir(candidate.path, matcher, skippedDirs, stats)) continue;
|
|
97
|
+
if (matcher.matchWithReason(candidate.path, false).ignored) {
|
|
98
|
+
stats.filesIgnored++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
entries.push({ path: candidate.path, size: candidate.size });
|
|
102
|
+
stats.filesIncluded++;
|
|
103
|
+
stats.totalSize += candidate.size;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
files: {
|
|
108
|
+
entries,
|
|
109
|
+
read(path) {
|
|
110
|
+
if (!byPath.has(path)) throw new Error(`plugin file '${path}' is not listed`);
|
|
111
|
+
return read(path);
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
stats,
|
|
115
|
+
matcher,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The order a sorted directory walk visits files: component by component,
|
|
121
|
+
* each compared by code point. Two paths never share a prefix that is a
|
|
122
|
+
* file in one and a directory in the other, so a shorter path is never a
|
|
123
|
+
* prefix of a longer one at the point they differ.
|
|
124
|
+
*/
|
|
125
|
+
export function compareWalkOrder(a: string, b: string): number {
|
|
126
|
+
const as = a.split("/");
|
|
127
|
+
const bs = b.split("/");
|
|
128
|
+
const depth = Math.min(as.length, bs.length);
|
|
129
|
+
for (let i = 0; i < depth; i++) {
|
|
130
|
+
const x = as[i] ?? "";
|
|
131
|
+
const y = bs[i] ?? "";
|
|
132
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
133
|
+
}
|
|
134
|
+
return as.length - bs.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* True when an ancestor directory of `path` is ignored as a directory. The
|
|
139
|
+
* walk checks a directory once and skips its subtree, so each ignored
|
|
140
|
+
* directory counts once whatever it holds.
|
|
141
|
+
*/
|
|
142
|
+
function underSkippedDir(path: string, matcher: Matcher, skipped: Set<string>, stats: SelectionStats): boolean {
|
|
143
|
+
const parts = path.split("/");
|
|
144
|
+
let dir = "";
|
|
145
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
146
|
+
dir = dir === "" ? (parts[i] ?? "") : `${dir}/${parts[i] ?? ""}`;
|
|
147
|
+
if (skipped.has(dir)) return true;
|
|
148
|
+
if (matcher.matchWithReason(dir, true).ignored) {
|
|
149
|
+
skipped.add(dir);
|
|
150
|
+
stats.dirsSkipped++;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const decoder = new TextDecoder();
|
|
158
|
+
|
|
159
|
+
function decode(bytes: Uint8Array): string {
|
|
160
|
+
return decoder.decode(bytes);
|
|
161
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words every client prints for the same thing.
|
|
3
|
+
*
|
|
4
|
+
* The CLI's `validate -f`, `push plugin --dry-run` and `install`, and the
|
|
5
|
+
* console's install preview, all describe a package to the user; a
|
|
6
|
+
* platform where the same fact is named differently on two surfaces has
|
|
7
|
+
* two vocabularies. The labels here are the docs' words
|
|
8
|
+
* (`docs/vocabulary.md`), kept once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PluginDialect } from "../types.js";
|
|
12
|
+
|
|
13
|
+
/** Human labels for the four dialects, in the vocabulary the docs use. */
|
|
14
|
+
export const DIALECT_LABELS: Readonly<Record<PluginDialect, string>> = {
|
|
15
|
+
"agent-plugins": "Agent Plugins 1.0",
|
|
16
|
+
claude: "Claude Code plugin",
|
|
17
|
+
cursor: "Cursor plugin",
|
|
18
|
+
codex: "Codex plugin",
|
|
19
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@stigmer/plugin-package/client`: what every client that installs a
|
|
3
|
+
* plugin does identically.
|
|
4
|
+
*
|
|
5
|
+
* The main entry reads a package; this entry is for the client that holds
|
|
6
|
+
* a tree and must turn it into the push the server expects. Two clients do
|
|
7
|
+
* that today, the CLI from a directory and the console from a marketplace
|
|
8
|
+
* host's file list, and a plugin installed from either must have one
|
|
9
|
+
* digest for one tree, be refused with one sentence for one mistake, and be
|
|
10
|
+
* described in one vocabulary. So the pieces live here, once:
|
|
11
|
+
*
|
|
12
|
+
* - the gitignore-compatible ignore engine and the security defaults
|
|
13
|
+
* (`ignore/`), pure over the text of the ignore files;
|
|
14
|
+
* - the selection rule (`select.ts`): which files a push carries, in the
|
|
15
|
+
* order the CLI's original walk produced them;
|
|
16
|
+
* - the archive and its digest (`archive.ts`): the deterministic zip and
|
|
17
|
+
* the SHA-256 the server records;
|
|
18
|
+
* - the grammars (`refs.ts`): an install ref and a GitHub marketplace
|
|
19
|
+
* source, with their refusal sentences;
|
|
20
|
+
* - the vocabulary (`vocabulary.ts`): the labels both surfaces print.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here touches the filesystem, the network or any `node:*` module.
|
|
23
|
+
* A client's edge (a directory walk, a fetch) produces the candidates and
|
|
24
|
+
* the bytes; this entry decides what becomes of them.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export { DEFAULT_PATTERNS } from "./client/ignore/defaults.js";
|
|
28
|
+
export { matchName } from "./client/ignore/match.js";
|
|
29
|
+
export {
|
|
30
|
+
type IgnoreSources,
|
|
31
|
+
type MatchReason,
|
|
32
|
+
Matcher,
|
|
33
|
+
Reason,
|
|
34
|
+
REASON_TEXT,
|
|
35
|
+
SOURCE_CLI,
|
|
36
|
+
SOURCE_DEFAULTS,
|
|
37
|
+
SOURCE_GITIGNORE,
|
|
38
|
+
SOURCE_STIGMERIGNORE,
|
|
39
|
+
buildMatcher,
|
|
40
|
+
} from "./client/ignore/matcher.js";
|
|
41
|
+
export { MatchResult, type Pattern, parsePattern } from "./client/ignore/pattern.js";
|
|
42
|
+
export {
|
|
43
|
+
type CandidateFile,
|
|
44
|
+
type PluginSelection,
|
|
45
|
+
type SelectPluginFilesOptions,
|
|
46
|
+
type SelectionStats,
|
|
47
|
+
IGNORE_FILE_NAMES,
|
|
48
|
+
compareWalkOrder,
|
|
49
|
+
selectPluginFiles,
|
|
50
|
+
} from "./client/select.js";
|
|
51
|
+
export { DETERMINISTIC_ZIP_MTIME, archivePlugin, digestArchive } from "./client/archive.js";
|
|
52
|
+
export {
|
|
53
|
+
type GitHubMarketplaceSource,
|
|
54
|
+
type GitHubSourceOutcome,
|
|
55
|
+
type InstallRef,
|
|
56
|
+
type InstallRefOutcome,
|
|
57
|
+
GITHUB_SOURCE_SHAPE,
|
|
58
|
+
INSTALL_REF_SHAPE,
|
|
59
|
+
OFFICIAL_MARKETPLACE_NAME,
|
|
60
|
+
describeGitHubSource,
|
|
61
|
+
formatInstallRef,
|
|
62
|
+
isOwnerRepo,
|
|
63
|
+
looksLikePath,
|
|
64
|
+
parseGitHubSource,
|
|
65
|
+
parseInstallRef,
|
|
66
|
+
} from "./client/refs.js";
|
|
67
|
+
export { DIALECT_LABELS } from "./client/vocabulary.js";
|
package/src/files.ts
CHANGED
|
@@ -58,6 +58,12 @@ 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 marketplace file in any dialect. The largest public catalogue
|
|
63
|
+
* (`cursor/plugins`, 79 entries with descriptions) is under 32 KB; the cap
|
|
64
|
+
* leaves room for catalogues an order of magnitude larger.
|
|
65
|
+
*/
|
|
66
|
+
marketplace: 1024 * 1024,
|
|
61
67
|
} as const;
|
|
62
68
|
|
|
63
69
|
export type PluginDocumentClass = keyof typeof PLUGIN_DOCUMENT_LIMITS;
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@stigmer/plugin-package`: the public surface.
|
|
3
3
|
*
|
|
4
|
-
* `readPluginPackage`
|
|
5
|
-
*
|
|
6
|
-
* (`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* `readPluginPackage` reads one plugin; `readMarketplace` reads a catalogue
|
|
5
|
+
* of them. Everything else here is what a consumer needs to supply its
|
|
6
|
+
* input (`PluginFiles`, the caps), route a directory (`MANIFEST_LOCATIONS`,
|
|
7
|
+
* `hasPluginManifest`, `MARKETPLACE_LOCATIONS`, `hasMarketplaceFile`), or
|
|
8
|
+
* render an outcome (the finding kinds and `isErrorKind`). The dialect
|
|
9
|
+
* readers and normalisers are internal: a consumer never composes a partial
|
|
10
|
+
* read.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
export { hasPluginManifest, isValidPluginName } from "./detect.js";
|
|
@@ -32,6 +34,7 @@ export {
|
|
|
32
34
|
export { SUB_AGENT_INSTRUCTIONS_MIN, classifyModel } from "./normalise/sub-agents.js";
|
|
33
35
|
export { PLACEHOLDER_PATTERN, VARIABLE_NAME_PATTERN } from "./placeholders.js";
|
|
34
36
|
export type {
|
|
37
|
+
Finding,
|
|
35
38
|
FindingContext,
|
|
36
39
|
PluginErrorKind,
|
|
37
40
|
PluginFinding,
|
|
@@ -39,6 +42,19 @@ export type {
|
|
|
39
42
|
PluginReadOutcome,
|
|
40
43
|
PluginWarningKind,
|
|
41
44
|
} from "./outcome.js";
|
|
45
|
+
export { MARKETPLACE_LOCATIONS } from "./marketplace/messages.js";
|
|
46
|
+
export type {
|
|
47
|
+
Marketplace,
|
|
48
|
+
MarketplaceDialect,
|
|
49
|
+
MarketplaceEntry,
|
|
50
|
+
MarketplaceErrorKind,
|
|
51
|
+
MarketplaceFinding,
|
|
52
|
+
MarketplaceFindingKind,
|
|
53
|
+
MarketplaceOwner,
|
|
54
|
+
MarketplaceReadOutcome,
|
|
55
|
+
MarketplaceWarningKind,
|
|
56
|
+
} from "./marketplace/outcome.js";
|
|
57
|
+
export { hasMarketplaceFile, readMarketplace } from "./marketplace/read-marketplace.js";
|
|
42
58
|
export { readPluginPackage } from "./read-plugin-package.js";
|
|
43
59
|
export type {
|
|
44
60
|
IgnoredComponent,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one sentence for every marketplace finding kind, and the collector
|
|
3
|
+
* bound to them.
|
|
4
|
+
*
|
|
5
|
+
* The same rule as the plugin vocabulary: copy lives here and nowhere else,
|
|
6
|
+
* the CLI prints these sentences verbatim, and the `Record` types are
|
|
7
|
+
* exhaustive so a kind without a sentence does not compile. The four
|
|
8
|
+
* locations are named in the not-found sentence because that is the one an
|
|
9
|
+
* author meets first when a file sits in the wrong place (Claude Code's own
|
|
10
|
+
* issue tracker shows how often).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { at, FindingCollector, q, type Sentence } from "../messages.js";
|
|
14
|
+
import type { MarketplaceDialect, MarketplaceErrorKind, MarketplaceWarningKind } from "./outcome.js";
|
|
15
|
+
|
|
16
|
+
/** The four marketplace file locations, in the precedence the reader applies. */
|
|
17
|
+
export const MARKETPLACE_LOCATIONS: Readonly<Record<MarketplaceDialect, string>> = {
|
|
18
|
+
stigmer: "marketplace.json",
|
|
19
|
+
claude: ".claude-plugin/marketplace.json",
|
|
20
|
+
cursor: ".cursor-plugin/marketplace.json",
|
|
21
|
+
codex: ".agents/plugins/marketplace.json",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const locationList = Object.values(MARKETPLACE_LOCATIONS)
|
|
25
|
+
.map((location) => q(location))
|
|
26
|
+
.join(", ");
|
|
27
|
+
|
|
28
|
+
const ERROR_MESSAGES: Readonly<Record<MarketplaceErrorKind, Sentence>> = {
|
|
29
|
+
"marketplace-not-found": () => `no marketplace file found: expected one of ${locationList}`,
|
|
30
|
+
"marketplace-too-large": (c) => `${q(c.path)} is ${c.subject ?? ""} bytes, above the ${c.detail ?? ""} byte cap for a marketplace file`,
|
|
31
|
+
"marketplace-unreadable": (c) => `${q(c.path)} is not a readable marketplace file: ${c.detail ?? "parse error"}`,
|
|
32
|
+
"marketplace-field-type": (c) => `${q(c.path)} field ${q(c.subject)} must be ${c.detail ?? "of another type"}`,
|
|
33
|
+
"marketplace-name-missing": (c) => `${q(c.path)} has no 'name'; a marketplace needs one so its plugins can be addressed as '<marketplace>/<plugin>'`,
|
|
34
|
+
"marketplace-name-invalid": (c) =>
|
|
35
|
+
`${q(c.path)} name ${q(c.subject)} is invalid: 1 to 64 characters of a-z, 0-9, '-' and '.', starting and ending alphanumeric, no '--' or '..'`,
|
|
36
|
+
"marketplace-plugins-missing": (c) => `${q(c.path)} has no 'plugins' list`,
|
|
37
|
+
"entry-shape": (c) => `${q(c.path)} plugins[${c.subject ?? ""}] must be an object with 'name' and 'source'`,
|
|
38
|
+
"entry-name-missing": (c) => `${q(c.path)} plugins[${c.subject ?? ""}] has no 'name'`,
|
|
39
|
+
"entry-name-invalid": (c) =>
|
|
40
|
+
`${q(c.path)} plugin ${q(c.subject)} has an invalid name: 1 to 64 characters of a-z, 0-9, '-' and '.', starting and ending alphanumeric, no '--' or '..'`,
|
|
41
|
+
"entry-name-duplicate": (c) => `${q(c.path)} lists plugin ${q(c.subject)} more than once; 'install ${c.subject ?? ""}' would be ambiguous`,
|
|
42
|
+
"entry-source-missing": (c) => `${q(c.path)} plugin ${q(c.subject)} has no 'source'`,
|
|
43
|
+
"entry-source-escapes-root": (c) =>
|
|
44
|
+
`${q(c.path)} plugin ${q(c.subject)} has source ${q(c.detail)} outside the marketplace root; a source is a directory inside the marketplace`,
|
|
45
|
+
"default-unknown": (c) => `${q(c.path)} names ${q(c.subject)} as a default but lists no plugin of that name`,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const WARNING_MESSAGES: Readonly<Record<MarketplaceWarningKind, Sentence>> = {
|
|
49
|
+
"entry-source-unsupported": (c) =>
|
|
50
|
+
`plugin ${q(c.subject)}${at(c.path)} has source ${q(c.detail)}, a form Stigmer does not fetch (only a directory inside the marketplace); not offered`,
|
|
51
|
+
"entry-directory-missing": (c) =>
|
|
52
|
+
`plugin ${q(c.subject)}${at(c.path)} names directory ${q(c.detail)}, which the marketplace does not contain; not offered`,
|
|
53
|
+
"entry-not-a-plugin": (c) =>
|
|
54
|
+
`plugin ${q(c.subject)}${at(c.path)} names directory ${q(c.detail)}, which holds no plugin manifest; not offered`,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The marketplace reader's collector, bound to the marketplace vocabulary. */
|
|
58
|
+
export class MarketplaceFindings extends FindingCollector<MarketplaceErrorKind, MarketplaceWarningKind> {
|
|
59
|
+
constructor() {
|
|
60
|
+
super(ERROR_MESSAGES, WARNING_MESSAGES);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a marketplace is once read, and the closed vocabulary of findings a
|
|
3
|
+
* marketplace read can produce.
|
|
4
|
+
*
|
|
5
|
+
* A marketplace is a directory tree with a marketplace file that lists the
|
|
6
|
+
* plugins it offers, each as a name and a directory inside the tree. That is
|
|
7
|
+
* the convention Cursor (`.cursor-plugin/marketplace.json`), Claude Code
|
|
8
|
+
* (`.claude-plugin/marketplace.json`) and Codex (`.agents/plugins/
|
|
9
|
+
* marketplace.json`) already publish, and the agent-plugins.org
|
|
10
|
+
* specification leaves marketplaces client-owned, so there is no open
|
|
11
|
+
* format to defer to; Stigmer's own file sits at the tree root as
|
|
12
|
+
* `marketplace.json`, the position the open plugin manifest takes. The four
|
|
13
|
+
* dialects reduce to one shape here: a client that can read this shape can
|
|
14
|
+
* install from any of the four catalogues.
|
|
15
|
+
*
|
|
16
|
+
* Two error postures, stated as the plugin reader states them. A marketplace
|
|
17
|
+
* FILE that is broken (unreadable, unnamed, no plugin list, a source that
|
|
18
|
+
* escapes the tree, two entries with one name) refuses the marketplace: a
|
|
19
|
+
* catalogue that cannot be trusted as a whole is not offered at all. An
|
|
20
|
+
* ENTRY that cannot be installed (a source form this reader does not fetch,
|
|
21
|
+
* a directory the tree does not hold, a directory with no plugin manifest)
|
|
22
|
+
* is dropped with a warning: a user adding a public catalogue of eighty
|
|
23
|
+
* plugins should still see the seventy-nine that work, and a catalogue's
|
|
24
|
+
* own static suite asserts zero warnings so the publisher hears about the
|
|
25
|
+
* one. Every kind has exactly one sentence in `messages.ts` and exactly one
|
|
26
|
+
* adversarial fixture.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Finding } from "../outcome.js";
|
|
30
|
+
|
|
31
|
+
/** The marketplace file dialects, in the precedence the reader applies. */
|
|
32
|
+
export type MarketplaceDialect = "stigmer" | "claude" | "cursor" | "codex";
|
|
33
|
+
|
|
34
|
+
export type MarketplaceErrorKind =
|
|
35
|
+
// The file
|
|
36
|
+
| "marketplace-not-found"
|
|
37
|
+
| "marketplace-too-large"
|
|
38
|
+
| "marketplace-unreadable"
|
|
39
|
+
| "marketplace-field-type"
|
|
40
|
+
| "marketplace-name-missing"
|
|
41
|
+
| "marketplace-name-invalid"
|
|
42
|
+
| "marketplace-plugins-missing"
|
|
43
|
+
// Entries
|
|
44
|
+
| "entry-shape"
|
|
45
|
+
| "entry-name-missing"
|
|
46
|
+
| "entry-name-invalid"
|
|
47
|
+
| "entry-name-duplicate"
|
|
48
|
+
| "entry-source-missing"
|
|
49
|
+
| "entry-source-escapes-root"
|
|
50
|
+
// Defaults
|
|
51
|
+
| "default-unknown";
|
|
52
|
+
|
|
53
|
+
export type MarketplaceWarningKind =
|
|
54
|
+
| "entry-source-unsupported"
|
|
55
|
+
| "entry-directory-missing"
|
|
56
|
+
| "entry-not-a-plugin";
|
|
57
|
+
|
|
58
|
+
export type MarketplaceFindingKind = MarketplaceErrorKind | MarketplaceWarningKind;
|
|
59
|
+
|
|
60
|
+
export type MarketplaceFinding = Finding<MarketplaceFindingKind>;
|
|
61
|
+
|
|
62
|
+
export interface MarketplaceOwner {
|
|
63
|
+
readonly name?: string;
|
|
64
|
+
readonly email?: string;
|
|
65
|
+
readonly url?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One installable plugin the marketplace offers. */
|
|
69
|
+
export interface MarketplaceEntry {
|
|
70
|
+
/** The name a user installs it by; unique within the marketplace. */
|
|
71
|
+
readonly name: string;
|
|
72
|
+
/** The plugin's directory, marketplace-relative with no leading `./`; the empty string is the tree root. */
|
|
73
|
+
readonly dir: string;
|
|
74
|
+
/** The catalogue's own one-line description, when the file carries one. */
|
|
75
|
+
readonly description?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Marketplace {
|
|
79
|
+
/** The marketplace's identifier, the prefix in `install <marketplace>/<plugin>`. */
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly description?: string;
|
|
82
|
+
readonly owner?: MarketplaceOwner;
|
|
83
|
+
/** The dialect whose file named the marketplace. */
|
|
84
|
+
readonly dialect: MarketplaceDialect;
|
|
85
|
+
/** The marketplace file that was read, tree-relative. */
|
|
86
|
+
readonly path: string;
|
|
87
|
+
/** Installable entries, in the file's order; entries the reader dropped are in the warnings. */
|
|
88
|
+
readonly plugins: readonly MarketplaceEntry[];
|
|
89
|
+
/** Entry names a client bootstraps with, in install order; empty when the file names none. */
|
|
90
|
+
readonly defaults: readonly string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type MarketplaceReadOutcome =
|
|
94
|
+
| { readonly ok: true; readonly marketplace: Marketplace; readonly warnings: readonly MarketplaceFinding[] }
|
|
95
|
+
| { readonly ok: false; readonly errors: readonly MarketplaceFinding[]; readonly warnings: readonly MarketplaceFinding[] };
|