@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 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
- ├── marketplace.json # optional
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 — `../` and absolute paths are
148
- rejected; the marketplace repo is the distribution unit. `defaultEnabled:
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,
@@ -66,20 +66,62 @@ function listJsFiles(pluginDir, dirs) {
66
66
  return [...names].sort()
67
67
  }
68
68
 
69
- // mcp.json holds opencode's mcp entry shape; the server names are its keys
70
- function listMcpServers(pluginDir) {
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
- const parsed = JSON.parse(readFileSync(join(pluginDir, "mcp.json"), "utf8"))
73
- if (isRecord(parsed)) return Object.keys(parsed).sort()
74
- } catch {}
75
- return []
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
- return typeof declared === "string" ? join(dir, declared) : join(plugin.dir, "mcp.json")
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
@@ -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
- function readEntries(marketplaceDir) {
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 raw = readJsonRecord(join(marketplaceDir, "marketplace.json"))
53
- if (!Array.isArray(raw?.plugins)) return entries
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 servers = rel === null ? undefined : readJsonRecord(join(marketplaceDir, rel))
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 = join(marketplaceDir, "marketplace.json")
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
- let servers = null
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
- let servers = null
52
- try {
53
- const parsed = JSON.parse(readFileSync(mcpFile, "utf8"))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wntic/ocm",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "File-based plugin marketplace for opencode: skills, agents, commands distributed via git repos",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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)) findings.push(error(`${rel}: directory name must match ${NAME_RE}`))
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(join(marketplaceDir, "marketplace.json")), renames)
35
+ collectRenames(readJsonRecord(marketplaceManifestFile(marketplaceDir)), renames)
35
36
  return renames
36
37
  }
@@ -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
- const file = join(root, "marketplace.json")
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
- for (const [server, value] of Object.entries(parsed)) {
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,3 +1,7 @@
1
1
  {
2
- "time": { "type": "local", "command": ["date"], "enabled": true }
2
+ "everything": {
3
+ "type": "local",
4
+ "command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
5
+ "enabled": true
6
+ }
3
7
  }
@@ -1,6 +1,9 @@
1
1
  {
2
- "description": "One command, agent, skill, plugin and mcp server — the demo kit",
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "demo-kit",
3
4
  "version": "0.1.0",
4
- "category": "example",
5
- "tags": ["demo"]
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
  }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "release-kit",
3
+ "description": "Ship a release and write its notes — the cross-tool demo kit",
4
+ "version": "0.1.0",
5
+ "author": { "name": "ocm" },
6
+ "commands": "./commands.claude"
7
+ }
@@ -1,6 +1,9 @@
1
1
  {
2
- "description": "Ship a release and write its notes — the cross-tool demo kit",
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "release-kit",
3
4
  "version": "0.1.0",
4
- "category": "example",
5
- "tags": ["release"]
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
  }