@wntic/ocm 0.1.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.
Files changed (64) hide show
  1. package/README.md +286 -0
  2. package/bin/ocm.ts +7 -0
  3. package/loader/config.js +47 -0
  4. package/loader/core.d.ts +256 -0
  5. package/loader/core.js +40 -0
  6. package/loader/discovery.js +162 -0
  7. package/loader/links.js +193 -0
  8. package/loader/lint.js +83 -0
  9. package/loader/manifest.js +158 -0
  10. package/loader/marketplace.js +199 -0
  11. package/loader/materialize.js +218 -0
  12. package/loader/mcp.js +124 -0
  13. package/loader/mutations.js +102 -0
  14. package/loader/ocm-loader.js +21 -0
  15. package/loader/paths.js +20 -0
  16. package/loader/registry.js +163 -0
  17. package/loader/search.js +55 -0
  18. package/loader/source.js +100 -0
  19. package/loader/sync.js +151 -0
  20. package/loader/trust.js +114 -0
  21. package/loader/ui-dialog.js +46 -0
  22. package/loader/ui-marketplaces.js +187 -0
  23. package/loader/ui-plugins.js +101 -0
  24. package/loader/ui-trust.js +68 -0
  25. package/loader/ui.js +109 -0
  26. package/package.json +32 -0
  27. package/src/commands/doctor-config.ts +79 -0
  28. package/src/commands/doctor-links.ts +170 -0
  29. package/src/commands/doctor.ts +144 -0
  30. package/src/commands/info.ts +159 -0
  31. package/src/commands/list.ts +44 -0
  32. package/src/commands/marketplace.ts +99 -0
  33. package/src/commands/plugins.ts +127 -0
  34. package/src/commands/search.ts +86 -0
  35. package/src/commands/trust.ts +146 -0
  36. package/src/commands/update-report.ts +123 -0
  37. package/src/commands/update.ts +160 -0
  38. package/src/commands/validate-files.ts +145 -0
  39. package/src/commands/validate.ts +77 -0
  40. package/src/discovery.ts +36 -0
  41. package/src/findings.ts +33 -0
  42. package/src/git.ts +24 -0
  43. package/src/index.ts +175 -0
  44. package/src/install.ts +15 -0
  45. package/src/loader.ts +185 -0
  46. package/src/manifest-lint.ts +189 -0
  47. package/src/migrate.ts +117 -0
  48. package/src/paths.ts +22 -0
  49. package/src/probe.ts +52 -0
  50. package/src/registry.ts +25 -0
  51. package/src/renames.ts +83 -0
  52. package/src/report.ts +13 -0
  53. package/src/types.ts +70 -0
  54. package/template/marketplace.json +17 -0
  55. package/template/plugins/demo-kit/agents/reviewer.md +20 -0
  56. package/template/plugins/demo-kit/commands/tdd.md +12 -0
  57. package/template/plugins/demo-kit/mcp.json +3 -0
  58. package/template/plugins/demo-kit/plugin/notify.js +4 -0
  59. package/template/plugins/demo-kit/plugin.json +6 -0
  60. package/template/plugins/demo-kit/skills/code-review/SKILL.md +21 -0
  61. package/template/plugins/release-kit/commands/ship.md +11 -0
  62. package/template/plugins/release-kit/commands.claude/ship.md +11 -0
  63. package/template/plugins/release-kit/plugin.json +6 -0
  64. package/template/plugins/release-kit/skills/release-notes/SKILL.md +14 -0
@@ -0,0 +1,162 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { isRecord } from "./registry.js"
4
+
5
+ // plugin names become command/agent namespaces and file-name prefixes, so
6
+ // they must be lowercase kebab
7
+ export const PLUGIN_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
8
+
9
+ // `commands` and `command` (likewise agents, skills) are both opencode-valid
10
+ // source directories, so both are discovered
11
+ function listMdFiles(pluginDir, dirs) {
12
+ const names = new Set()
13
+ for (const dir of dirs) {
14
+ try {
15
+ for (const file of readdirSync(join(pluginDir, dir))) if (file.endsWith(".md")) names.add(file)
16
+ } catch {}
17
+ }
18
+ return [...names].sort()
19
+ }
20
+
21
+ function listSkillDirs(pluginDir, dirs) {
22
+ const found = new Set()
23
+ const seen = new Set()
24
+ const walk = (dir, rel) => {
25
+ let real
26
+ try {
27
+ real = realpathSync(dir)
28
+ } catch {
29
+ return
30
+ }
31
+ if (seen.has(real)) return
32
+ seen.add(real)
33
+ let entries
34
+ try {
35
+ entries = readdirSync(dir)
36
+ } catch {
37
+ return
38
+ }
39
+ for (const entry of entries) {
40
+ const child = join(dir, entry)
41
+ let isDir = false
42
+ try {
43
+ isDir = statSync(child).isDirectory()
44
+ } catch {}
45
+ if (!isDir) continue
46
+ const childRel = rel ? `${rel}/${entry}` : entry
47
+ if (existsSync(join(child, "SKILL.md"))) found.add(childRel)
48
+ walk(child, childRel)
49
+ }
50
+ }
51
+ for (const dir of dirs) walk(join(pluginDir, dir), "")
52
+ return [...found].sort()
53
+ }
54
+
55
+ // `plugin` and `plugins` are both opencode-valid source directories for
56
+ // server plugin modules (spec 06)
57
+ function listJsFiles(pluginDir, dirs) {
58
+ const names = new Set()
59
+ for (const dir of dirs) {
60
+ try {
61
+ for (const file of readdirSync(join(pluginDir, dir))) {
62
+ if (file.endsWith(".js") || file.endsWith(".ts")) names.add(file)
63
+ }
64
+ } catch {}
65
+ }
66
+ return [...names].sort()
67
+ }
68
+
69
+ // mcp.json holds opencode's mcp entry shape; the server names are its keys
70
+ function listMcpServers(pluginDir) {
71
+ 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 []
76
+ }
77
+
78
+ // 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)
80
+ export function mcpSourceFile(dir, entry, plugin) {
81
+ const declared = entry?.plugins?.[plugin.name]?.manifest?.mcpServers
82
+ return typeof declared === "string" ? join(dir, declared) : join(plugin.dir, "mcp.json")
83
+ }
84
+
85
+ // a name defined in both the singular and plural form of a component
86
+ // directory is a clash add refuses rather than guess (spec 06)
87
+ export function dirClashes(pluginDir) {
88
+ const clashes = []
89
+ const pairs = [
90
+ ["commands", "command", listMdFiles],
91
+ ["agents", "agent", listMdFiles],
92
+ ["skills", "skill", listSkillDirs],
93
+ ["plugin", "plugins", listJsFiles],
94
+ ]
95
+ for (const [a, b, list] of pairs) {
96
+ const other = list(pluginDir, [b])
97
+ for (const name of list(pluginDir, [a])) {
98
+ if (other.includes(name)) clashes.push(`${name} in both "${a}" and "${b}"`)
99
+ }
100
+ }
101
+ return clashes
102
+ }
103
+
104
+ // does this directory hold a SKILL.md at any depth? such entries get their
105
+ // own mirror, so they must never be linked into a sibling mirror
106
+ export function containsSkillMd(dir, seen = new Set()) {
107
+ let real
108
+ try {
109
+ real = realpathSync(dir)
110
+ } catch {
111
+ return false
112
+ }
113
+ if (seen.has(real)) return false
114
+ seen.add(real)
115
+ let entries
116
+ try {
117
+ entries = readdirSync(dir)
118
+ } catch {
119
+ return false
120
+ }
121
+ for (const entry of entries) {
122
+ if (entry === "SKILL.md") return true
123
+ const child = join(dir, entry)
124
+ let isDir = false
125
+ try {
126
+ isDir = statSync(child).isDirectory()
127
+ } catch {}
128
+ if (isDir && containsSkillMd(child, seen)) return true
129
+ }
130
+ return false
131
+ }
132
+
133
+ export function discoverPlugins(marketplaceDir) {
134
+ const plugins = []
135
+ const collect = (pluginDir) => {
136
+ const components = {}
137
+ const commands = listMdFiles(pluginDir, ["commands", "command"])
138
+ if (commands.length) components.command = commands
139
+ const agents = listMdFiles(pluginDir, ["agents", "agent"])
140
+ if (agents.length) components.agent = agents
141
+ const skills = listSkillDirs(pluginDir, ["skills", "skill"])
142
+ if (skills.length) components.skill = skills
143
+ const pluginFiles = listJsFiles(pluginDir, ["plugin", "plugins"])
144
+ if (pluginFiles.length) components.plugin = pluginFiles
145
+ const mcpServers = listMcpServers(pluginDir)
146
+ if (mcpServers.length) components.mcp = mcpServers
147
+ if (!Object.keys(components).length) return
148
+ const name = pluginDir.replace(/\/+$/, "").split("/").pop()
149
+ plugins.push({ name: name.toLowerCase(), dir: pluginDir, components })
150
+ }
151
+ const pluginsDir = join(marketplaceDir, "plugins")
152
+ if (existsSync(pluginsDir)) {
153
+ for (const entry of readdirSync(pluginsDir).sort()) {
154
+ const dir = join(pluginsDir, entry)
155
+ try {
156
+ if (statSync(dir).isDirectory()) collect(dir)
157
+ } catch {}
158
+ }
159
+ }
160
+ if (!plugins.length) collect(marketplaceDir)
161
+ return plugins
162
+ }
@@ -0,0 +1,193 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ readlinkSync,
8
+ renameSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs"
13
+ import { dirname, join, relative } from "node:path"
14
+ import { containsSkillMd } from "./discovery.js"
15
+
16
+ const RENDERED_MARKER = "ocm: rendered from "
17
+
18
+ // raw-string prefix compare only: a raw target must never be compared against
19
+ // a realpath'd directory (macOS puts temp dirs behind /var -> /private/var)
20
+ function insideDir(target, dir) {
21
+ return target === dir || target.startsWith(dir + "/")
22
+ }
23
+
24
+ function targetsInside(target, dirs) {
25
+ return dirs.some((dir) => insideDir(target, dir))
26
+ }
27
+
28
+ function owningPlugin(target) {
29
+ const match = target.match(/\/plugins\/([^/]+)\//)
30
+ return match ? match[1] : null
31
+ }
32
+
33
+ // --force takes over an unowned path by moving it under the displaced dir,
34
+ // never deleting: a mistake stays recoverable and the path is reported
35
+ function takeOver(dest, ctx) {
36
+ if (!ctx.force) {
37
+ ctx.warnings.push(`skipped ${dest}: not managed by ocm`)
38
+ return false
39
+ }
40
+ const target = join(ctx.displacedDir, dest)
41
+ try {
42
+ mkdirSync(dirname(target), { recursive: true })
43
+ renameSync(dest, target)
44
+ } catch (err) {
45
+ ctx.warnings.push(`failed to displace ${dest}: ${err instanceof Error ? err.message : String(err)}`)
46
+ return false
47
+ }
48
+ ctx.warnings.push(`displaced ${dest} -> ${target}`)
49
+ return true
50
+ }
51
+
52
+ export function isRenderedFile(path) {
53
+ try {
54
+ return readFileSync(path, "utf8").includes(RENDERED_MARKER)
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ // link: dest is owned iff it is a symlink whose target resolves inside a
61
+ // managed marketplace directory
62
+ export function link(source, dest, ctx, plugin, component) {
63
+ let existing
64
+ try {
65
+ existing = readlinkSync(dest)
66
+ } catch {}
67
+ if (existing === source) return "ok"
68
+ let stat
69
+ try {
70
+ stat = lstatSync(dest)
71
+ } catch {}
72
+ if (stat && !stat.isSymbolicLink()) {
73
+ if (!takeOver(dest, ctx)) return "skipped"
74
+ }
75
+ if (existing !== undefined && existsSync(dest)) {
76
+ if (!targetsInside(existing, ctx.managed)) {
77
+ if (!takeOver(dest, ctx)) return "skipped"
78
+ } else {
79
+ const owner = owningPlugin(existing) ?? ctx.name
80
+ if (owner !== plugin || !insideDir(existing, ctx.dir)) {
81
+ ctx.warnings.push(`${plugin}:${component} conflicts with ${owner}:${component}`)
82
+ return "refused"
83
+ }
84
+ }
85
+ }
86
+ if (stat) {
87
+ try {
88
+ rmSync(dest, { force: true, recursive: true })
89
+ } catch {}
90
+ }
91
+ try {
92
+ symlinkSync(source, dest)
93
+ return "created"
94
+ } catch (err) {
95
+ ctx.warnings.push(`failed ${dest}: ${err instanceof Error ? err.message : String(err)}`)
96
+ return "skipped"
97
+ }
98
+ }
99
+
100
+ // render: dest is owned iff it carries the rendered marker
101
+ function render(source, dest, transform, ctx) {
102
+ let output
103
+ try {
104
+ const body = transform(readFileSync(source, "utf8"))
105
+ if (body === null || body === undefined) return "skipped"
106
+ output = body.endsWith("\n") ? body : `${body}\n`
107
+ output += `<!-- ${RENDERED_MARKER}${relative(ctx.dir, source)} @ ${ctx.revision} -->\n`
108
+ } catch (err) {
109
+ ctx.warnings.push(`failed ${source}: ${err instanceof Error ? err.message : String(err)}`)
110
+ return "skipped"
111
+ }
112
+ let stat
113
+ try {
114
+ stat = lstatSync(dest)
115
+ } catch {}
116
+ if (stat) {
117
+ if (stat.isSymbolicLink()) {
118
+ if (existsSync(dest)) {
119
+ if (!takeOver(dest, ctx)) return "skipped"
120
+ }
121
+ try {
122
+ rmSync(dest, { force: true })
123
+ } catch {}
124
+ } else if (!stat.isFile()) {
125
+ if (!takeOver(dest, ctx)) return "skipped"
126
+ } else {
127
+ let current
128
+ try {
129
+ current = readFileSync(dest, "utf8")
130
+ } catch {}
131
+ if (current === output) return "ok"
132
+ if (current === undefined || !current.includes(RENDERED_MARKER)) {
133
+ if (!takeOver(dest, ctx)) return "skipped"
134
+ }
135
+ }
136
+ }
137
+ try {
138
+ writeFileSync(dest, output)
139
+ return "created"
140
+ } catch (err) {
141
+ ctx.warnings.push(`failed ${dest}: ${err instanceof Error ? err.message : String(err)}`)
142
+ return "skipped"
143
+ }
144
+ }
145
+
146
+ // remove owned entries not in the desired set; a symlink is ours iff it
147
+ // points into the current marketplace, anything else iff `extra` proves it.
148
+ // `scope` restricts the pass to one plugin's entries (plugin-scoped update).
149
+ export function gcTargets(dir, desired, ctx, extra, scope) {
150
+ let removed = 0
151
+ let entries
152
+ try {
153
+ entries = readdirSync(dir)
154
+ } catch {
155
+ return 0
156
+ }
157
+ for (const entry of entries) {
158
+ if (desired.has(entry)) continue
159
+ if (scope !== undefined && !entry.startsWith(scope)) continue
160
+ const path = join(dir, entry)
161
+ let target
162
+ try {
163
+ target = readlinkSync(path)
164
+ } catch {}
165
+ const owned = target !== undefined ? insideDir(target, ctx.dir) : extra ? extra(path) : false
166
+ if (!owned) continue
167
+ try {
168
+ rmSync(path, { force: true, recursive: true })
169
+ removed += 1
170
+ } catch {}
171
+ }
172
+ return removed
173
+ }
174
+
175
+ // mirror: a real directory whose entries are link() or render()
176
+ export function mirror(sourceDir, destDir, plan, ctx, plugin, component) {
177
+ mkdirSync(destDir, { recursive: true })
178
+ let entries
179
+ try {
180
+ entries = readdirSync(sourceDir)
181
+ } catch {
182
+ entries = []
183
+ }
184
+ const desired = new Set()
185
+ for (const entry of entries) {
186
+ if (containsSkillMd(join(sourceDir, entry))) continue
187
+ desired.add(entry)
188
+ const transform = plan[entry]
189
+ if (transform) render(join(sourceDir, entry), join(destDir, entry), transform, ctx)
190
+ else link(join(sourceDir, entry), join(destDir, entry), ctx, plugin, component)
191
+ }
192
+ return gcTargets(destDir, desired, ctx, isRenderedFile)
193
+ }
package/loader/lint.js ADDED
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { join, relative } from "node:path"
3
+ import { discoverPlugins } from "./discovery.js"
4
+
5
+ // spec 11: the portable skill frontmatter subset. opencode tolerates extras
6
+ // today, so this is a lint, not a rule — it exists so a plugin does not
7
+ // become a liability if that tolerance regresses.
8
+ const PORTABLE_KEYS = new Set(["name", "description", "license", "compatibility", "metadata"])
9
+
10
+ // top-level keys only: an indented line belongs to the value above it
11
+ function frontmatterKeys(content) {
12
+ if (!content.startsWith("---\n")) return []
13
+ const close = content.indexOf("\n---\n", 3)
14
+ if (close === -1) return []
15
+ const keys = []
16
+ for (const line of content.slice(4, close).split("\n")) {
17
+ const match = line.match(/^[^\s:]+(?=:)/)
18
+ if (match) keys.push(match[0])
19
+ }
20
+ return keys
21
+ }
22
+
23
+ // `commands` and `command` (likewise the other types) are both opencode-valid
24
+ // source directories, so both are searched
25
+ function sourceFile(pluginDir, dirs, name) {
26
+ for (const dir of dirs) {
27
+ const file = join(pluginDir, dir, name)
28
+ if (existsSync(file)) return file
29
+ }
30
+ return null
31
+ }
32
+
33
+ // CLAUDE_PLUGIN_ROOT is the marketplace root, so a reference resolves only
34
+ // when the plugins/<name>/ segment is written out (spec 11)
35
+ function lintPluginRootRefs(plugin, dirs, files, marketplaceDir, warnings) {
36
+ for (const name of files ?? []) {
37
+ const file = sourceFile(plugin.dir, dirs, name)
38
+ if (!file) continue
39
+ let content
40
+ try {
41
+ content = readFileSync(file, "utf8")
42
+ } catch {
43
+ continue
44
+ }
45
+ for (const match of content.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}([^"\s]*)/g)) {
46
+ if (!match[1].startsWith("/plugins/")) {
47
+ warnings.push(
48
+ `${relative(marketplaceDir, file)}: \${CLAUDE_PLUGIN_ROOT}${match[1]} will not resolve` +
49
+ " — CLAUDE_PLUGIN_ROOT is the marketplace root, write the plugins/<name>/ segment out",
50
+ )
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ // spec 11 phase 1: the cross-tool lint that `ocm validate` (spec 12) renders.
57
+ // Warnings only — an install never fails on a finding.
58
+ export function lintCrossTool(marketplaceDir) {
59
+ const warnings = []
60
+ for (const plugin of discoverPlugins(marketplaceDir)) {
61
+ for (const rel of plugin.components.skill ?? []) {
62
+ const file = sourceFile(plugin.dir, ["skills", "skill"], join(rel, "SKILL.md"))
63
+ if (!file) continue
64
+ let content
65
+ try {
66
+ content = readFileSync(file, "utf8")
67
+ } catch {
68
+ continue
69
+ }
70
+ for (const key of frontmatterKeys(content)) {
71
+ if (!PORTABLE_KEYS.has(key)) {
72
+ warnings.push(
73
+ `${relative(marketplaceDir, file)}: non-portable frontmatter "${key}"` +
74
+ " — outside the portable subset (name, description, license, compatibility, metadata)",
75
+ )
76
+ }
77
+ }
78
+ }
79
+ lintPluginRootRefs(plugin, ["commands", "command"], plugin.components.command, marketplaceDir, warnings)
80
+ lintPluginRootRefs(plugin, ["agents", "agent"], plugin.components.agent, marketplaceDir, warnings)
81
+ }
82
+ return warnings
83
+ }
@@ -0,0 +1,158 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { dirClashes, discoverPlugins } from "./discovery.js"
4
+ import { isRecord } from "./registry.js"
5
+
6
+ function readJsonRecord(file) {
7
+ if (!existsSync(file)) return undefined
8
+ try {
9
+ const parsed = JSON.parse(readFileSync(file, "utf8"))
10
+ if (isRecord(parsed)) return parsed
11
+ } catch {}
12
+ return undefined
13
+ }
14
+
15
+ // a `source` or `mcpServers` path: ./-relative and inside the marketplace
16
+ function relativePath(source) {
17
+ if (!source.startsWith("./") || source.split("/").includes("..")) return null
18
+ return source.slice(2)
19
+ }
20
+
21
+ // the metadata fields plugin.json and a marketplace entry share; the entry
22
+ // wins because it is the more specific declaration (spec 06)
23
+ function metadataFrom(raw) {
24
+ const manifest = {}
25
+ if (!raw) return manifest
26
+ if (typeof raw.description === "string") manifest.description = raw.description
27
+ if (typeof raw.category === "string") manifest.category = raw.category
28
+ if (typeof raw.version === "string") manifest.version = raw.version
29
+ if (typeof raw.homepage === "string") manifest.homepage = raw.homepage
30
+ if (typeof raw.license === "string") manifest.license = raw.license
31
+ if (Array.isArray(raw.tags) && raw.tags.every((tag) => typeof tag === "string")) manifest.tags = raw.tags
32
+ if (Array.isArray(raw.keywords) && raw.keywords.every((keyword) => typeof keyword === "string")) {
33
+ manifest.keywords = raw.keywords
34
+ }
35
+ return manifest
36
+ }
37
+
38
+ // spec 06: a plugin.json name that disagrees with the directory name is a
39
+ // warning; the directory name wins because that is what the materializer
40
+ // namespaces from
41
+ export function nameDisagreement(pluginDir, pluginName) {
42
+ const raw = readJsonRecord(join(pluginDir, "plugin.json"))
43
+ if (typeof raw?.name !== "string" || raw.name === pluginName) return null
44
+ return (
45
+ `plugin "${pluginName}": plugin.json name "${raw.name}" disagrees with the directory name "${pluginName}"; ` +
46
+ "the directory name wins — rename the directory or fix plugin.json"
47
+ )
48
+ }
49
+
50
+ function readEntries(marketplaceDir) {
51
+ const entries = new Map()
52
+ const raw = readJsonRecord(join(marketplaceDir, "marketplace.json"))
53
+ if (!Array.isArray(raw?.plugins)) return entries
54
+ for (const entry of raw.plugins) {
55
+ if (isRecord(entry) && typeof entry.name === "string") entries.set(entry.name, entry)
56
+ }
57
+ return entries
58
+ }
59
+
60
+ export function discoverMarketplace(marketplaceDir) {
61
+ const warnings = []
62
+ const entries = readEntries(marketplaceDir)
63
+ const plugins = new Map()
64
+ for (const plugin of discoverPlugins(marketplaceDir)) {
65
+ const entry = entries.get(plugin.name)
66
+ const disagreement = nameDisagreement(plugin.dir, plugin.name)
67
+ if (disagreement) warnings.push(disagreement)
68
+ const fromPlugin = metadataFrom(readJsonRecord(join(plugin.dir, "plugin.json")))
69
+ const fromEntry = metadataFrom(entry)
70
+ const manifest = { ...fromPlugin, ...fromEntry }
71
+ // the disagreement is cached so info can annotate it from the registry
72
+ // alone, with the marketplace directory deleted (spec 09)
73
+ const conflicts = Object.keys(fromEntry).filter(
74
+ (key) => key in fromPlugin && JSON.stringify(fromPlugin[key]) !== JSON.stringify(fromEntry[key]),
75
+ )
76
+ if (conflicts.length) manifest.conflicts = conflicts.sort()
77
+ const components = { ...plugin.components }
78
+ if (typeof entry?.defaultEnabled === "boolean") manifest.defaultEnabled = entry.defaultEnabled
79
+ if (typeof entry?.mcpServers === "string") {
80
+ const rel = relativePath(entry.mcpServers)
81
+ const servers = rel === null ? undefined : readJsonRecord(join(marketplaceDir, rel))
82
+ if (servers) {
83
+ manifest.mcpServers = entry.mcpServers
84
+ components.mcp = Object.keys(servers).sort()
85
+ } else {
86
+ warnings.push(`plugin "${plugin.name}": mcpServers path "${entry.mcpServers}" is not a JSON object in ${marketplaceDir}`)
87
+ }
88
+ }
89
+ plugins.set(plugin.name, {
90
+ name: plugin.name,
91
+ dir: plugin.dir,
92
+ source: plugin.dir,
93
+ components,
94
+ manifest,
95
+ })
96
+ }
97
+ // a bad source is a warning, never a failure: the plugin directory is
98
+ // still discovered by the scan (spec 06)
99
+ for (const [name, entry] of entries) {
100
+ const rel = typeof entry.source === "string" ? relativePath(entry.source) : null
101
+ if (rel === null || !existsSync(join(marketplaceDir, rel))) {
102
+ warnings.push(`plugin "${name}" source ${JSON.stringify(entry.source ?? null)} not found in ${marketplaceDir}; the entry is skipped`)
103
+ }
104
+ }
105
+ return { plugins, warnings }
106
+ }
107
+
108
+ // add refuses a marketplace whose singular and plural component directories
109
+ // define the same name, or that ships a tui plugin (spec 06)
110
+ export function discoveryError(plugins) {
111
+ for (const plugin of plugins) {
112
+ const clashes = dirClashes(plugin.dir)
113
+ if (clashes.length) {
114
+ return (
115
+ `plugin "${plugin.name}" has a component name clash: ${clashes.join("; ")}\n` +
116
+ " remove one directory of each clashing pair; ocm refuses to guess"
117
+ )
118
+ }
119
+ const tui = tuiModule(plugin)
120
+ if (tui) {
121
+ return (
122
+ `plugin "${plugin.name}" ships a tui plugin (${tui}): tui plugins are not supported\n` +
123
+ " shipping one would edit tui.json on behalf of third-party code; ship a server plugin ({ id, server }) instead"
124
+ )
125
+ }
126
+ }
127
+ return null
128
+ }
129
+
130
+ // a static check: a module default-exporting a tui member is a tui plugin
131
+ function tuiModule(plugin) {
132
+ for (const file of plugin.components.plugin ?? []) {
133
+ for (const dir of ["plugin", "plugins"]) {
134
+ let content
135
+ try {
136
+ content = readFileSync(join(plugin.dir, dir, file), "utf8")
137
+ } catch {
138
+ continue
139
+ }
140
+ if (/\btui\s*:/.test(content)) return `${dir}/${file}`
141
+ }
142
+ }
143
+ return null
144
+ }
145
+
146
+ export function readManifest(marketplaceDir) {
147
+ const file = join(marketplaceDir, "marketplace.json")
148
+ if (!existsSync(file)) return {}
149
+ try {
150
+ const parsed = JSON.parse(readFileSync(file, "utf8"))
151
+ return {
152
+ name: isRecord(parsed) && typeof parsed.name === "string" ? parsed.name : undefined,
153
+ description: isRecord(parsed) && typeof parsed.description === "string" ? parsed.description : undefined,
154
+ }
155
+ } catch {
156
+ return {}
157
+ }
158
+ }