@wntic/ocm 0.1.0 → 0.2.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/README.md +10 -3
- package/bin/ocm.ts +0 -0
- package/loader/core.d.ts +1 -0
- package/loader/core.js +1 -1
- package/loader/discovery.js +50 -8
- package/loader/manifest.js +48 -6
- package/loader/mcp.js +2 -6
- package/loader/trust.js +4 -6
- package/package.json +1 -1
- package/src/commands/validate.ts +42 -2
- package/src/discovery.ts +2 -1
- package/src/manifest-lint.ts +69 -3
- package/template/.claude-plugin/marketplace.json +12 -0
- package/template/plugins/demo-kit/mcp.json +5 -1
- package/template/plugins/demo-kit/plugin.json +6 -3
- package/template/plugins/release-kit/.claude-plugin/plugin.json +7 -0
- package/template/plugins/release-kit/plugin.json +6 -3
- /package/template/{marketplace.json → .opencode-plugin/marketplace.json} +0 -0
package/README.md
CHANGED
|
@@ -79,7 +79,8 @@ automatic migration (below) prints the old → new mapping when it relinks.
|
|
|
79
79
|
|
|
80
80
|
```
|
|
81
81
|
my-marketplace/
|
|
82
|
-
├──
|
|
82
|
+
├── .opencode-plugin/
|
|
83
|
+
│ └── marketplace.json # optional
|
|
83
84
|
└── plugins/
|
|
84
85
|
├── demo-kit/
|
|
85
86
|
│ ├── plugin.json # optional
|
|
@@ -123,6 +124,10 @@ marketplace root.
|
|
|
123
124
|
|
|
124
125
|
### `marketplace.json`
|
|
125
126
|
|
|
127
|
+
The manifest lives at `.opencode-plugin/marketplace.json`. A root
|
|
128
|
+
`marketplace.json` still works — `ocm validate` warns — but new marketplaces
|
|
129
|
+
should use the new location.
|
|
130
|
+
|
|
126
131
|
```json
|
|
127
132
|
{
|
|
128
133
|
"name": "my-marketplace",
|
|
@@ -144,8 +149,10 @@ marketplace root.
|
|
|
144
149
|
```
|
|
145
150
|
|
|
146
151
|
Only a plugin entry's `name` and `source` are required. `source` is a
|
|
147
|
-
`./`-relative path inside the marketplace —
|
|
148
|
-
|
|
152
|
+
`./`-relative path inside the marketplace — it says where the plugin is.
|
|
153
|
+
`mcpServers` is `./`-relative inside the plugin directory — it says what is
|
|
154
|
+
in the plugin. `../` and absolute paths are rejected; the marketplace repo is
|
|
155
|
+
the distribution unit. `defaultEnabled:
|
|
149
156
|
false` keeps a plugin disabled in an `auto` marketplace.
|
|
150
157
|
|
|
151
158
|
### `plugin.json`
|
package/bin/ocm.ts
CHANGED
|
File without changes
|
package/loader/core.d.ts
CHANGED
|
@@ -241,6 +241,7 @@ export declare function discoverMarketplace(marketplaceDir: string): CoreDiscove
|
|
|
241
241
|
export declare function discoveryError(plugins: CoreManifestPlugin[]): string | null
|
|
242
242
|
export declare function nameDisagreement(pluginDir: string, pluginName: string): string | null
|
|
243
243
|
export declare function readManifest(marketplaceDir: string): { name?: string; description?: string }
|
|
244
|
+
export declare function marketplaceManifestFile(marketplaceDir: string): string
|
|
244
245
|
export declare function componentRoot(entry: CoreMarketplaceEntry): string
|
|
245
246
|
export declare function incumbentMarketplace(registry: CoreRegistry, self: string, pluginName: string): string | undefined
|
|
246
247
|
export declare function registerPlugins(registry: CoreRegistry, name: string, plugins: CoreManifestPlugin[]): void
|
package/loader/core.js
CHANGED
|
@@ -26,7 +26,7 @@ export { setSkillsPath } from "./config.js"
|
|
|
26
26
|
export { git, isGitRepo, pullRepo, syncAll } from "./sync.js"
|
|
27
27
|
export { denyEntry, executableComponents, grantEntry, trustFingerprint } from "./trust.js"
|
|
28
28
|
export { isGitUrl, manifestName, marketplaceNameFromUrl, normaliseMarketplaceName, parseSource } from "./source.js"
|
|
29
|
-
export { discoveryError, discoverMarketplace, nameDisagreement, readManifest } from "./manifest.js"
|
|
29
|
+
export { discoveryError, discoverMarketplace, marketplaceManifestFile, nameDisagreement, readManifest } from "./manifest.js"
|
|
30
30
|
export {
|
|
31
31
|
addMarketplace,
|
|
32
32
|
componentRoot,
|
package/loader/discovery.js
CHANGED
|
@@ -66,20 +66,62 @@ function listJsFiles(pluginDir, dirs) {
|
|
|
66
66
|
return [...names].sort()
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
|
|
69
|
+
// An Agent Plugins server entry, translated to opencode's mcp shape. Only the
|
|
70
|
+
// fields opencode has a home for survive: it has no equivalent of `cwd`.
|
|
71
|
+
function toOpencodeServer(entry) {
|
|
72
|
+
if (entry.type === "stdio") {
|
|
73
|
+
// AP requires "command"; without it the file is malformed and the entry
|
|
74
|
+
// is skipped rather than materialized as a broken server (spec 14 §9)
|
|
75
|
+
if (typeof entry.command !== "string") return null
|
|
76
|
+
const args = Array.isArray(entry.args) ? entry.args : []
|
|
77
|
+
const server = { type: "local", command: [entry.command, ...args], enabled: true }
|
|
78
|
+
if (isRecord(entry.env)) server.environment = entry.env
|
|
79
|
+
return server
|
|
80
|
+
}
|
|
81
|
+
if (entry.type === "streamable-http" || entry.type === "sse") {
|
|
82
|
+
const server = { type: "remote", url: entry.url, enabled: true }
|
|
83
|
+
if (isRecord(entry.headers)) server.headers = entry.headers
|
|
84
|
+
return server
|
|
85
|
+
}
|
|
86
|
+
return entry
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A plugin's mcp.json is either opencode's own shape — a bare map of server
|
|
90
|
+
// name to entry — or the Agent Plugins shape, `{ $schema, mcpServers }`, which
|
|
91
|
+
// Codex and Cursor read. Accepting both means one file per plugin serves every
|
|
92
|
+
// client and the two cannot drift apart.
|
|
93
|
+
export function readMcpServers(file) {
|
|
94
|
+
let parsed
|
|
71
95
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
parsed = JSON.parse(readFileSync(file, "utf8"))
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
if (!isRecord(parsed)) return null
|
|
101
|
+
if (!isRecord(parsed.mcpServers)) return parsed
|
|
102
|
+
const servers = {}
|
|
103
|
+
for (const [name, entry] of Object.entries(parsed.mcpServers)) {
|
|
104
|
+
if (!isRecord(entry)) continue
|
|
105
|
+
const server = toOpencodeServer(entry)
|
|
106
|
+
if (server !== null) servers[name] = server
|
|
107
|
+
}
|
|
108
|
+
return servers
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listMcpServers(pluginDir) {
|
|
112
|
+
const servers = readMcpServers(join(pluginDir, "mcp.json"))
|
|
113
|
+
return servers ? Object.keys(servers).sort() : []
|
|
76
114
|
}
|
|
77
115
|
|
|
78
116
|
// the mcp source file: the marketplace entry's mcpServers path when it
|
|
79
|
-
// declares one, else the plugin directory's own mcp.json (spec 06)
|
|
117
|
+
// declares one, else the plugin directory's own mcp.json (spec 06). The
|
|
118
|
+
// declared path is plugin-relative; the marketplace root is a deprecated
|
|
119
|
+
// fallback discovery has already warned about (spec 15 §3)
|
|
80
120
|
export function mcpSourceFile(dir, entry, plugin) {
|
|
81
121
|
const declared = entry?.plugins?.[plugin.name]?.manifest?.mcpServers
|
|
82
|
-
|
|
122
|
+
if (typeof declared !== "string") return join(plugin.dir, "mcp.json")
|
|
123
|
+
const pluginFile = join(plugin.dir, declared.slice(2))
|
|
124
|
+
return existsSync(pluginFile) ? pluginFile : join(dir, declared.slice(2))
|
|
83
125
|
}
|
|
84
126
|
|
|
85
127
|
// a name defined in both the singular and plural form of a component
|
package/loader/manifest.js
CHANGED
|
@@ -32,6 +32,13 @@ function metadataFrom(raw) {
|
|
|
32
32
|
if (Array.isArray(raw.keywords) && raw.keywords.every((keyword) => typeof keyword === "string")) {
|
|
33
33
|
manifest.keywords = raw.keywords
|
|
34
34
|
}
|
|
35
|
+
// spec 14: category/tags under extensions["dev.wntic.ocm"] win over the
|
|
36
|
+
// top-level form, which stays readable for marketplaces that predate it
|
|
37
|
+
const ext = isRecord(raw.extensions) ? raw.extensions["dev.wntic.ocm"] : undefined
|
|
38
|
+
if (isRecord(ext)) {
|
|
39
|
+
if (typeof ext.category === "string") manifest.category = ext.category
|
|
40
|
+
if (Array.isArray(ext.tags) && ext.tags.every((tag) => typeof tag === "string")) manifest.tags = ext.tags
|
|
41
|
+
}
|
|
35
42
|
return manifest
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -47,10 +54,29 @@ export function nameDisagreement(pluginDir, pluginName) {
|
|
|
47
54
|
)
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
// spec 15: .opencode-plugin/marketplace.json wins; the root path stays legal
|
|
58
|
+
export function marketplaceManifestFile(marketplaceDir) {
|
|
59
|
+
const preferred = join(marketplaceDir, ".opencode-plugin", "marketplace.json")
|
|
60
|
+
if (existsSync(preferred)) return preferred
|
|
61
|
+
return join(marketplaceDir, "marketplace.json")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readEntries(marketplaceDir, warnings) {
|
|
51
65
|
const entries = new Map()
|
|
52
|
-
const
|
|
53
|
-
if (!
|
|
66
|
+
const file = marketplaceManifestFile(marketplaceDir)
|
|
67
|
+
if (!existsSync(file)) return entries
|
|
68
|
+
let raw
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(readFileSync(file, "utf8"))
|
|
71
|
+
} catch {
|
|
72
|
+
raw = undefined
|
|
73
|
+
}
|
|
74
|
+
if (!isRecord(raw)) {
|
|
75
|
+
// a broken manifest is reported, never routed around (spec 15 §4)
|
|
76
|
+
warnings.push(`${file}: not a valid JSON object — the manifest is ignored`)
|
|
77
|
+
return entries
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(raw.plugins)) return entries
|
|
54
80
|
for (const entry of raw.plugins) {
|
|
55
81
|
if (isRecord(entry) && typeof entry.name === "string") entries.set(entry.name, entry)
|
|
56
82
|
}
|
|
@@ -59,7 +85,7 @@ function readEntries(marketplaceDir) {
|
|
|
59
85
|
|
|
60
86
|
export function discoverMarketplace(marketplaceDir) {
|
|
61
87
|
const warnings = []
|
|
62
|
-
const entries = readEntries(marketplaceDir)
|
|
88
|
+
const entries = readEntries(marketplaceDir, warnings)
|
|
63
89
|
const plugins = new Map()
|
|
64
90
|
for (const plugin of discoverPlugins(marketplaceDir)) {
|
|
65
91
|
const entry = entries.get(plugin.name)
|
|
@@ -77,11 +103,27 @@ export function discoverMarketplace(marketplaceDir) {
|
|
|
77
103
|
const components = { ...plugin.components }
|
|
78
104
|
if (typeof entry?.defaultEnabled === "boolean") manifest.defaultEnabled = entry.defaultEnabled
|
|
79
105
|
if (typeof entry?.mcpServers === "string") {
|
|
106
|
+
// spec 15 §3: plugin-relative first, the marketplace root is deprecated
|
|
80
107
|
const rel = relativePath(entry.mcpServers)
|
|
81
|
-
const
|
|
108
|
+
const pluginFile = rel === null ? null : join(plugin.dir, rel)
|
|
109
|
+
const marketplaceFile = rel === null ? null : join(marketplaceDir, rel)
|
|
110
|
+
const inPlugin = pluginFile !== null && existsSync(pluginFile)
|
|
111
|
+
const inMarketplace = marketplaceFile !== null && existsSync(marketplaceFile)
|
|
112
|
+
const servers = inPlugin ? readJsonRecord(pluginFile) : inMarketplace ? readJsonRecord(marketplaceFile) : undefined
|
|
82
113
|
if (servers) {
|
|
83
114
|
manifest.mcpServers = entry.mcpServers
|
|
84
115
|
components.mcp = Object.keys(servers).sort()
|
|
116
|
+
if (inPlugin && inMarketplace && pluginFile !== marketplaceFile) {
|
|
117
|
+
warnings.push(
|
|
118
|
+
`plugin "${plugin.name}": mcpServers "${entry.mcpServers}" resolves to both ${pluginFile} and ${marketplaceFile}` +
|
|
119
|
+
"; the plugin-relative file wins",
|
|
120
|
+
)
|
|
121
|
+
} else if (!inPlugin && inMarketplace) {
|
|
122
|
+
warnings.push(
|
|
123
|
+
`plugin "${plugin.name}": mcpServers "${entry.mcpServers}" resolves only against the marketplace root — deprecated;` +
|
|
124
|
+
` expected ${pluginFile}, found ${marketplaceFile}`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
85
127
|
} else {
|
|
86
128
|
warnings.push(`plugin "${plugin.name}": mcpServers path "${entry.mcpServers}" is not a JSON object in ${marketplaceDir}`)
|
|
87
129
|
}
|
|
@@ -144,7 +186,7 @@ function tuiModule(plugin) {
|
|
|
144
186
|
}
|
|
145
187
|
|
|
146
188
|
export function readManifest(marketplaceDir) {
|
|
147
|
-
const file =
|
|
189
|
+
const file = marketplaceManifestFile(marketplaceDir)
|
|
148
190
|
if (!existsSync(file)) return {}
|
|
149
191
|
try {
|
|
150
192
|
const parsed = JSON.parse(readFileSync(file, "utf8"))
|
package/loader/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
-
import { mcpSourceFile, PLUGIN_NAME_RE } from "./discovery.js"
|
|
2
|
+
import { mcpSourceFile, PLUGIN_NAME_RE, readMcpServers } from "./discovery.js"
|
|
3
3
|
import { OPENCODE_CONFIG_FILE, OPENCODE_DIR } from "./paths.js"
|
|
4
4
|
import { isRecord } from "./registry.js"
|
|
5
5
|
import { componentKey } from "./trust.js"
|
|
@@ -64,11 +64,7 @@ export function syncMcp(plugins, dir, entry, enabled, approved, warnings) {
|
|
|
64
64
|
if (enabled !== null && !enabled.has(plugin.name)) continue
|
|
65
65
|
const file = mcpSourceFile(dir, entry, plugin)
|
|
66
66
|
if (!(plugin.components.mcp ?? []).length && !existsSync(file)) continue
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const parsed = JSON.parse(readFileSync(file, "utf8"))
|
|
70
|
-
if (isRecord(parsed)) servers = parsed
|
|
71
|
-
} catch {}
|
|
67
|
+
const servers = readMcpServers(file)
|
|
72
68
|
if (servers === null) {
|
|
73
69
|
warnings.push(`skipped ${file}: not a JSON object`)
|
|
74
70
|
continue
|
package/loader/trust.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto"
|
|
2
2
|
import { readFileSync } from "node:fs"
|
|
3
3
|
import { join, relative } from "node:path"
|
|
4
|
-
import { discoverPlugins, mcpSourceFile } from "./discovery.js"
|
|
4
|
+
import { discoverPlugins, mcpSourceFile, readMcpServers } from "./discovery.js"
|
|
5
5
|
import { isRecord } from "./registry.js"
|
|
6
6
|
|
|
7
7
|
// canonical JSON: keys sorted at every level, so reordering mcp.json leaves a
|
|
@@ -48,11 +48,9 @@ export function executableComponents(dir, entry) {
|
|
|
48
48
|
})
|
|
49
49
|
}
|
|
50
50
|
const mcpFile = mcpSourceFile(dir, entry, plugin)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (isRecord(parsed)) servers = parsed
|
|
55
|
-
} catch {}
|
|
51
|
+
// both mcp.json shapes, so the fingerprint covers the servers that will
|
|
52
|
+
// actually run rather than an Agent Plugins wrapper key
|
|
53
|
+
const servers = readMcpServers(mcpFile)
|
|
56
54
|
for (const [server, value] of Object.entries(servers ?? {})) {
|
|
57
55
|
components.push({
|
|
58
56
|
rel: `${relative(dir, mcpFile)}:${server}`,
|
package/package.json
CHANGED
package/src/commands/validate.ts
CHANGED
|
@@ -6,11 +6,15 @@ import { join, relative } from "node:path"
|
|
|
6
6
|
import { dirClashes, discoverPlugins, lintCrossTool } from "../../loader/core.js"
|
|
7
7
|
import type { CoreDiscoveredPlugin } from "../../loader/core.js"
|
|
8
8
|
import { error, reportFindings, warning, type Finding } from "../findings"
|
|
9
|
-
import { NAME_RE, lintMarketplaceJson, lintMcpJson, lintPluginJson, type MarketplaceManifest } from "../manifest-lint"
|
|
9
|
+
import { NAME_RE, lintMarketplaceJson, lintMcpJson, lintPluginJson, lintSkillDepth, type MarketplaceManifest } from "../manifest-lint"
|
|
10
10
|
import { lintMarkdown, lintPluginJs, lintSkill } from "./validate-files"
|
|
11
11
|
|
|
12
12
|
const TYPO_FILES = new Set(["plugin.ts", "SKILLS.md", "Skill.md"])
|
|
13
13
|
|
|
14
|
+
// spec 14 §3: the Agent Plugins name charset — a-z 0-9 - ., alphanumeric at
|
|
15
|
+
// both ends, 1–64 chars
|
|
16
|
+
const AP_NAME_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/
|
|
17
|
+
|
|
14
18
|
export function validate(path?: string): void {
|
|
15
19
|
const root = path ?? process.cwd()
|
|
16
20
|
if (!existsSync(root)) throw new Error(`marketplace directory "${root}" does not exist`)
|
|
@@ -33,7 +37,20 @@ function lintPlugin(
|
|
|
33
37
|
findings: Finding[],
|
|
34
38
|
): void {
|
|
35
39
|
const rel = relative(root, plugin.dir) || "."
|
|
36
|
-
if (!NAME_RE.test(plugin.name))
|
|
40
|
+
if (!NAME_RE.test(plugin.name)) {
|
|
41
|
+
// spec 14 §3: an AP-valid name gets the namespacing explanation, not a
|
|
42
|
+
// bare regex failure — ocm's stricter rule stays
|
|
43
|
+
if (AP_NAME_RE.test(plugin.name) && plugin.name.length <= 64) {
|
|
44
|
+
findings.push(
|
|
45
|
+
error(
|
|
46
|
+
`${rel}: name "${plugin.name}" is valid Agent Plugins but not ocm — plugin names become command and agent` +
|
|
47
|
+
" namespaces on disk (<plugin>:<item>.md), and a dot there is a new failure surface; rename to kebab-case",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
} else {
|
|
51
|
+
findings.push(error(`${rel}: directory name must match ${NAME_RE}`))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
37
54
|
if (plugin.name.length > 64) findings.push(error(`${rel}: directory name is longer than 64 characters`))
|
|
38
55
|
for (const clash of dirClashes(plugin.dir)) {
|
|
39
56
|
findings.push(error(`${rel}: ${clash} — both produce the same materialized name`))
|
|
@@ -42,6 +59,7 @@ function lintPlugin(
|
|
|
42
59
|
const record = lintPluginJson(root, plugin.dir, plugin.name, findings)
|
|
43
60
|
lintMcpJson(root, plugin.dir, findings)
|
|
44
61
|
const entry = manifest.entries.get(plugin.name)
|
|
62
|
+
lintMcpServersBase(root, plugin, entry, findings)
|
|
45
63
|
const fromMarketplace = typeof entry?.version === "string" ? entry.version : undefined
|
|
46
64
|
const fromPlugin = typeof record?.version === "string" ? record.version : undefined
|
|
47
65
|
if (fromMarketplace && fromPlugin && fromMarketplace !== fromPlugin) {
|
|
@@ -56,11 +74,33 @@ function lintPlugin(
|
|
|
56
74
|
for (const dir of plugin.components.skill ?? []) {
|
|
57
75
|
lintSkill(root, plugin, dir, skills, findings)
|
|
58
76
|
}
|
|
77
|
+
lintSkillDepth(root, plugin.dir, plugin.components.skill ?? [], findings)
|
|
59
78
|
for (const file of plugin.components.plugin ?? []) {
|
|
60
79
|
lintPluginJs(root, plugin, file, findings)
|
|
61
80
|
}
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
// spec 15 §3: mcpServers resolves against the plugin directory; a value
|
|
84
|
+
// that resolves only against the marketplace root is deprecated
|
|
85
|
+
function lintMcpServersBase(
|
|
86
|
+
root: string,
|
|
87
|
+
plugin: CoreDiscoveredPlugin,
|
|
88
|
+
entry: Record<string, unknown> | undefined,
|
|
89
|
+
findings: Finding[],
|
|
90
|
+
): void {
|
|
91
|
+
const mcpServers = entry?.mcpServers
|
|
92
|
+
if (typeof mcpServers !== "string" || !mcpServers.startsWith("./") || mcpServers.split("/").includes("..")) return
|
|
93
|
+
const pluginFile = join(plugin.dir, mcpServers.slice(2))
|
|
94
|
+
const marketplaceFile = join(root, mcpServers.slice(2))
|
|
95
|
+
if (existsSync(pluginFile) || !existsSync(marketplaceFile)) return
|
|
96
|
+
findings.push(
|
|
97
|
+
warning(
|
|
98
|
+
`${relative(root, plugin.dir)}: mcpServers "${mcpServers}" resolves only against the marketplace root — deprecated; ` +
|
|
99
|
+
`expected ${relative(root, pluginFile)}, found ${relative(root, marketplaceFile)}`,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
64
104
|
function lintTypos(rel: string, pluginDir: string, findings: Finding[]): void {
|
|
65
105
|
if (existsSync(join(pluginDir, "skills")) && existsSync(join(pluginDir, "skill"))) {
|
|
66
106
|
findings.push(warning(`${rel}: both "skill" and "skills" exist — one is probably a typo`))
|
package/src/discovery.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
2
|
import { join } from "node:path"
|
|
3
|
+
import { marketplaceManifestFile } from "../loader/core.js"
|
|
3
4
|
import type { DiscoveredPlugin } from "./types"
|
|
4
5
|
|
|
5
6
|
export { discoverMarketplace, discoveryError, nameDisagreement, readManifest } from "../loader/core.js"
|
|
@@ -31,6 +32,6 @@ function collectRenames(raw: Record<string, unknown> | undefined, into: Record<s
|
|
|
31
32
|
export function readRenames(marketplaceDir: string, plugins: DiscoveredPlugin[]): Record<string, string | null> {
|
|
32
33
|
const renames: Record<string, string | null> = {}
|
|
33
34
|
for (const plugin of plugins) collectRenames(readJsonRecord(join(plugin.dir, "plugin.json")), renames)
|
|
34
|
-
collectRenames(readJsonRecord(
|
|
35
|
+
collectRenames(readJsonRecord(marketplaceManifestFile(marketplaceDir)), renames)
|
|
35
36
|
return renames
|
|
36
37
|
}
|
package/src/manifest-lint.ts
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
// dependency; the schema files exist for editors).
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs"
|
|
5
5
|
import { join, relative } from "node:path"
|
|
6
|
-
import { readRegistry } from "../loader/core.js"
|
|
6
|
+
import { marketplaceManifestFile, readRegistry } from "../loader/core.js"
|
|
7
7
|
import { error, warning, type Finding } from "./findings"
|
|
8
8
|
|
|
9
9
|
export const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
10
10
|
|
|
11
|
+
// spec 14 §2: pinned to 1.0.0, the published version — 1.1.0 is a Working
|
|
12
|
+
// Draft and a floating identifier is forbidden by §5.2 of the standard
|
|
13
|
+
const AP_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
|
14
|
+
|
|
11
15
|
const STRING_FIELDS = ["description", "version", "category", "homepage", "repository", "license"]
|
|
12
16
|
const ARRAY_FIELDS = ["tags", "keywords"]
|
|
13
17
|
|
|
@@ -126,7 +130,29 @@ export interface MarketplaceManifest {
|
|
|
126
130
|
|
|
127
131
|
export function lintMarketplaceJson(root: string, findings: Finding[]): MarketplaceManifest {
|
|
128
132
|
const manifest: MarketplaceManifest = { entries: new Map() }
|
|
129
|
-
|
|
133
|
+
// spec 14 §5: Codex's plugin manifest directory is not a catalog location,
|
|
134
|
+
// so this file is dead weight no client reads
|
|
135
|
+
const codexCatalog = join(root, ".codex-plugin", "marketplace.json")
|
|
136
|
+
if (existsSync(codexCatalog)) {
|
|
137
|
+
findings.push(
|
|
138
|
+
warning(`${relative(root, codexCatalog)}: not a catalog location — Codex reads .agents/plugins/marketplace.json; remove this file`),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
// spec 15 §2: both manifest locations present — the new path wins
|
|
142
|
+
const rootFile = join(root, "marketplace.json")
|
|
143
|
+
const file = marketplaceManifestFile(root)
|
|
144
|
+
if (file !== rootFile && existsSync(rootFile)) {
|
|
145
|
+
findings.push(
|
|
146
|
+
warning(`${relative(root, rootFile)}: ignored — ${relative(root, file)} wins; remove one of the two manifests`),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
// spec 15 §4: not ocm's business to police, but opencode reads .opencode/
|
|
150
|
+
// as project config when the repo is opened in it
|
|
151
|
+
if (existsSync(join(root, ".opencode"))) {
|
|
152
|
+
findings.push(
|
|
153
|
+
warning(".opencode/: opencode reads this directory as project config — opening this repo in opencode injects its contents"),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
130
156
|
if (!existsSync(file)) return manifest
|
|
131
157
|
const rel = relative(root, file)
|
|
132
158
|
const parsed = parseJson(file, rel, findings)
|
|
@@ -165,6 +191,22 @@ export function lintPluginJson(
|
|
|
165
191
|
const parsed = parseJson(file, rel, findings)
|
|
166
192
|
if (!parsed) return undefined
|
|
167
193
|
lintFields(rel, parsed, findings)
|
|
194
|
+
// spec 14 §7: only an unrecognised $schema is an error — a non-conformant
|
|
195
|
+
// plugin still works perfectly well in opencode
|
|
196
|
+
if (parsed.$schema === undefined) {
|
|
197
|
+
findings.push(warning(`${rel}: no "$schema" — not installable by Codex; pin to ${AP_PLUGIN_SCHEMA}`))
|
|
198
|
+
} else if (parsed.$schema !== AP_PLUGIN_SCHEMA) {
|
|
199
|
+
findings.push(error(`${rel}: "$schema" ${JSON.stringify(parsed.$schema)} is not recognised — pin to ${AP_PLUGIN_SCHEMA}`))
|
|
200
|
+
}
|
|
201
|
+
const legacy = ["category", "tags"].filter((key) => parsed[key] !== undefined)
|
|
202
|
+
if (legacy.length) {
|
|
203
|
+
findings.push(
|
|
204
|
+
warning(`${rel}: top-level ${legacy.map((key) => `"${key}"`).join(", ")} — move under extensions["dev.wntic.ocm"]`),
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
if (parsed.extensions !== undefined && !isRecord(parsed.extensions)) {
|
|
208
|
+
findings.push(warning(`${rel}: "extensions" is not an object — reported and ignored`))
|
|
209
|
+
}
|
|
168
210
|
if (parsed.author !== undefined && !isRecord(parsed.author)) {
|
|
169
211
|
findings.push(error(`${rel}: "author" must be an object`))
|
|
170
212
|
}
|
|
@@ -175,15 +217,39 @@ export function lintPluginJson(
|
|
|
175
217
|
return parsed
|
|
176
218
|
}
|
|
177
219
|
|
|
220
|
+
// spec 14 §7: Agent Plugins discovers only immediate children of skills/, so
|
|
221
|
+
// a deeper skill is invisible to Codex and Cursor even though ocm installs it
|
|
222
|
+
export function lintSkillDepth(root: string, pluginDir: string, skills: string[], findings: Finding[]): void {
|
|
223
|
+
for (const dir of skills) {
|
|
224
|
+
if (!dir.includes("/")) continue
|
|
225
|
+
findings.push(
|
|
226
|
+
warning(
|
|
227
|
+
`${relative(root, pluginDir)}/skills/${dir}: skill nested deeper than an immediate child of skills/` +
|
|
228
|
+
" — ocm materializes it, but Codex and Cursor will not see it",
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
178
234
|
export function lintMcpJson(root: string, pluginDir: string, findings: Finding[]): void {
|
|
179
235
|
const file = join(pluginDir, "mcp.json")
|
|
180
236
|
if (!existsSync(file)) return
|
|
181
237
|
const rel = relative(root, file)
|
|
182
238
|
const parsed = parseJson(file, rel, findings)
|
|
183
239
|
if (!parsed) return
|
|
184
|
-
|
|
240
|
+
// Either opencode's own shape — a bare map of server name to entry — or the
|
|
241
|
+
// Agent Plugins shape, `{ $schema, mcpServers }`. Both are accepted, so one
|
|
242
|
+
// file per plugin serves opencode, Codex and Cursor alike.
|
|
243
|
+
const mcpServers = isRecord(parsed.mcpServers) ? parsed.mcpServers : undefined
|
|
244
|
+
const servers = mcpServers ?? parsed
|
|
245
|
+
for (const [server, value] of Object.entries(servers)) {
|
|
246
|
+
if (server === "$schema") continue
|
|
185
247
|
if (!isRecord(value) || value.type === undefined) {
|
|
186
248
|
findings.push(error(`${rel}: entry "${server}" is missing "type"`))
|
|
249
|
+
} else if (mcpServers !== undefined && value.type === "stdio" && typeof value.command !== "string") {
|
|
250
|
+
// AP requires "command", so the file is malformed; the entry is skipped
|
|
251
|
+
// at install time too (spec 14 §9)
|
|
252
|
+
findings.push(warning(`${rel}: entry "${server}" is missing "command" — skipped; Agent Plugins requires it`))
|
|
187
253
|
}
|
|
188
254
|
}
|
|
189
255
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "demo-marketplace",
|
|
3
|
+
"owner": { "name": "ocm" },
|
|
4
|
+
"description": "Example marketplace shared by ocm and Claude Code",
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"name": "release-kit",
|
|
8
|
+
"source": "./plugins/release-kit",
|
|
9
|
+
"description": "Release helpers, authored for both opencode and Claude Code"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "demo-kit",
|
|
3
4
|
"version": "0.1.0",
|
|
4
|
-
"
|
|
5
|
-
"
|
|
5
|
+
"description": "One command, agent, skill, plugin and mcp server — the demo kit",
|
|
6
|
+
"extensions": {
|
|
7
|
+
"dev.wntic.ocm": { "category": "example", "tags": ["demo"] }
|
|
8
|
+
}
|
|
6
9
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "release-kit",
|
|
3
4
|
"version": "0.1.0",
|
|
4
|
-
"
|
|
5
|
-
"
|
|
5
|
+
"description": "Ship a release and write its notes — the cross-tool demo kit",
|
|
6
|
+
"extensions": {
|
|
7
|
+
"dev.wntic.ocm": { "category": "example", "tags": ["release"] }
|
|
8
|
+
}
|
|
6
9
|
}
|
|
File without changes
|