@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.
- package/README.md +286 -0
- package/bin/ocm.ts +7 -0
- package/loader/config.js +47 -0
- package/loader/core.d.ts +256 -0
- package/loader/core.js +40 -0
- package/loader/discovery.js +162 -0
- package/loader/links.js +193 -0
- package/loader/lint.js +83 -0
- package/loader/manifest.js +158 -0
- package/loader/marketplace.js +199 -0
- package/loader/materialize.js +218 -0
- package/loader/mcp.js +124 -0
- package/loader/mutations.js +102 -0
- package/loader/ocm-loader.js +21 -0
- package/loader/paths.js +20 -0
- package/loader/registry.js +163 -0
- package/loader/search.js +55 -0
- package/loader/source.js +100 -0
- package/loader/sync.js +151 -0
- package/loader/trust.js +114 -0
- package/loader/ui-dialog.js +46 -0
- package/loader/ui-marketplaces.js +187 -0
- package/loader/ui-plugins.js +101 -0
- package/loader/ui-trust.js +68 -0
- package/loader/ui.js +109 -0
- package/package.json +32 -0
- package/src/commands/doctor-config.ts +79 -0
- package/src/commands/doctor-links.ts +170 -0
- package/src/commands/doctor.ts +144 -0
- package/src/commands/info.ts +159 -0
- package/src/commands/list.ts +44 -0
- package/src/commands/marketplace.ts +99 -0
- package/src/commands/plugins.ts +127 -0
- package/src/commands/search.ts +86 -0
- package/src/commands/trust.ts +146 -0
- package/src/commands/update-report.ts +123 -0
- package/src/commands/update.ts +160 -0
- package/src/commands/validate-files.ts +145 -0
- package/src/commands/validate.ts +77 -0
- package/src/discovery.ts +36 -0
- package/src/findings.ts +33 -0
- package/src/git.ts +24 -0
- package/src/index.ts +175 -0
- package/src/install.ts +15 -0
- package/src/loader.ts +185 -0
- package/src/manifest-lint.ts +189 -0
- package/src/migrate.ts +117 -0
- package/src/paths.ts +22 -0
- package/src/probe.ts +52 -0
- package/src/registry.ts +25 -0
- package/src/renames.ts +83 -0
- package/src/report.ts +13 -0
- package/src/types.ts +70 -0
- package/template/marketplace.json +17 -0
- package/template/plugins/demo-kit/agents/reviewer.md +20 -0
- package/template/plugins/demo-kit/commands/tdd.md +12 -0
- package/template/plugins/demo-kit/mcp.json +3 -0
- package/template/plugins/demo-kit/plugin/notify.js +4 -0
- package/template/plugins/demo-kit/plugin.json +6 -0
- package/template/plugins/demo-kit/skills/code-review/SKILL.md +21 -0
- package/template/plugins/release-kit/commands/ship.md +11 -0
- package/template/plugins/release-kit/commands.claude/ship.md +11 -0
- package/template/plugins/release-kit/plugin.json +6 -0
- 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
|
+
}
|
package/loader/links.js
ADDED
|
@@ -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
|
+
}
|