@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,199 @@
|
|
|
1
|
+
import { rmSync } from "node:fs"
|
|
2
|
+
import { join, relative } from "node:path"
|
|
3
|
+
import { setSkillsPath } from "./config.js"
|
|
4
|
+
import { discoverMarketplace, discoveryError, readManifest } from "./manifest.js"
|
|
5
|
+
import { enabledPlugins, materialize, removeLinksFor } from "./materialize.js"
|
|
6
|
+
import { removeMcpKeys } from "./mcp.js"
|
|
7
|
+
import { LINKS_DIR } from "./paths.js"
|
|
8
|
+
import { loadRegistryForWrite, saveRegistry } from "./registry.js"
|
|
9
|
+
import { manifestName, normaliseMarketplaceName, parseSource, placeClone } from "./source.js"
|
|
10
|
+
import { git } from "./sync.js"
|
|
11
|
+
import { denyEntry, executableComponents, grantEntry } from "./trust.js"
|
|
12
|
+
|
|
13
|
+
// discovery roots at the subdir when the source was a tree url; git
|
|
14
|
+
// operations keep running against the clone root (spec 05)
|
|
15
|
+
export function componentRoot(entry) {
|
|
16
|
+
return entry.subdir ? join(entry.dir, entry.subdir) : entry.dir
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// plugin names are globally unique across marketplaces (spec 04, axis 4):
|
|
20
|
+
// the first marketplace to provide a name is the incumbent
|
|
21
|
+
export function incumbentMarketplace(registry, self, pluginName) {
|
|
22
|
+
for (const [name, entry] of Object.entries(registry.marketplaces)) {
|
|
23
|
+
if (name !== self && entry.plugins[pluginName]) return name
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerPlugins(registry, name, plugins) {
|
|
28
|
+
// every caller assigns or verifies the entry in the registry right before this
|
|
29
|
+
const entry = registry.marketplaces[name]
|
|
30
|
+
const root = componentRoot(entry)
|
|
31
|
+
const updated = {}
|
|
32
|
+
for (const plugin of plugins) {
|
|
33
|
+
const existing = entry.plugins[plugin.name]
|
|
34
|
+
const incumbent = incumbentMarketplace(registry, name, plugin.name)
|
|
35
|
+
// a colliding name registers disabled; a collision that has cleared
|
|
36
|
+
// registers as if fresh — enabled in auto, and in explicit only when
|
|
37
|
+
// the user installed it while it was colliding. A collision record
|
|
38
|
+
// was never chosen, so installedAt stays null (spec 02)
|
|
39
|
+
let enabled = existing?.collision
|
|
40
|
+
? entry.mode === "auto" || existing.installedAt !== null
|
|
41
|
+
: existing?.enabled ?? (entry.mode !== "explicit" && plugin.manifest.defaultEnabled !== false)
|
|
42
|
+
if (incumbent) enabled = false
|
|
43
|
+
const record = {
|
|
44
|
+
source: relative(root, plugin.dir),
|
|
45
|
+
components: plugin.components,
|
|
46
|
+
enabled,
|
|
47
|
+
installedAt: existing?.installedAt ?? (incumbent || entry.mode === "explicit" || !enabled ? null : entry.addedAt),
|
|
48
|
+
version: plugin.manifest.version ?? null,
|
|
49
|
+
manifest: plugin.manifest,
|
|
50
|
+
}
|
|
51
|
+
if (incumbent) record.collision = incumbent
|
|
52
|
+
updated[plugin.name] = record
|
|
53
|
+
}
|
|
54
|
+
entry.plugins = updated
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// spec 04, axis 4: plugin names are globally unique across marketplaces;
|
|
58
|
+
// adding a marketplace that ships a taken name fails with both sources named
|
|
59
|
+
function collisionError(registry, name, plugins) {
|
|
60
|
+
for (const plugin of plugins) {
|
|
61
|
+
const incumbent = incumbentMarketplace(registry, name, plugin.name)
|
|
62
|
+
if (incumbent) {
|
|
63
|
+
return `plugin "${plugin.name}" is already provided by marketplace "${incumbent}"; not adding "${name}". Remove one, or ask its author to rename.`
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// a failed add leaves no clone behind (a local directory is the user's);
|
|
70
|
+
// the discovery warnings ride the error so a refusal cannot swallow them
|
|
71
|
+
function addRefusal(message, warnings, parsed, dir) {
|
|
72
|
+
if (parsed.isGit) rmSync(dir, { recursive: true, force: true })
|
|
73
|
+
const error = new Error(message)
|
|
74
|
+
error.warnings = warnings
|
|
75
|
+
throw error
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// spec 05 add, minus the dialog: register, decide trust from the flag, save,
|
|
79
|
+
// materialize. With no flag the trust stays "none" and the executable
|
|
80
|
+
// components ship blocked, named in trustComponents for the prompt to render.
|
|
81
|
+
export async function addMarketplace(source, options = {}) {
|
|
82
|
+
const parsed = parseSource(source)
|
|
83
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
84
|
+
const mode = options.explicit ? "explicit" : "auto"
|
|
85
|
+
const ref = options.ref ?? parsed.ref
|
|
86
|
+
const fallback = parsed.isGit ? parsed.name : manifestName(readManifest(parsed.url).name) ?? parsed.name
|
|
87
|
+
const wanted = options.name ? normaliseMarketplaceName(options.name) : fallback
|
|
88
|
+
if (registry.marketplaces[wanted]) {
|
|
89
|
+
throw new Error(`marketplace "${wanted}" already added (use "ocm update ${wanted}")`)
|
|
90
|
+
}
|
|
91
|
+
const { name, dir } = parsed.isGit
|
|
92
|
+
? await placeClone(parsed, wanted, ref, registry, options.name !== undefined)
|
|
93
|
+
: { name: wanted, dir: parsed.url }
|
|
94
|
+
const root = parsed.subdir ? join(dir, parsed.subdir) : dir
|
|
95
|
+
const discovered = discoverMarketplace(root)
|
|
96
|
+
const plugins = [...discovered.plugins.values()]
|
|
97
|
+
if (!plugins.length) {
|
|
98
|
+
addRefusal(
|
|
99
|
+
`no plugins found in ${parsed.url}\n` +
|
|
100
|
+
` expected plugins/<name>/{commands,agents,skills}/ at the repository root\n` +
|
|
101
|
+
` run \`ocm scan ${parsed.url}\` to see what was found`,
|
|
102
|
+
discovered.warnings, parsed, dir,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
const refusal = discoveryError(plugins)
|
|
106
|
+
if (refusal) addRefusal(refusal, discovered.warnings, parsed, dir)
|
|
107
|
+
const collision = collisionError(registry, name, plugins)
|
|
108
|
+
if (collision) addRefusal(collision, discovered.warnings, parsed, dir)
|
|
109
|
+
const entry = {
|
|
110
|
+
url: parsed.url,
|
|
111
|
+
dir,
|
|
112
|
+
local: !parsed.isGit,
|
|
113
|
+
addedAt: new Date().toISOString(),
|
|
114
|
+
mode,
|
|
115
|
+
ref: parsed.isGit ? ref : null,
|
|
116
|
+
subdir: parsed.subdir,
|
|
117
|
+
revision: null,
|
|
118
|
+
syncIntervalMs: null,
|
|
119
|
+
trust: { code: "none" },
|
|
120
|
+
lastSync: null,
|
|
121
|
+
plugins: {},
|
|
122
|
+
}
|
|
123
|
+
registry.marketplaces[name] = entry
|
|
124
|
+
registerPlugins(registry, name, plugins)
|
|
125
|
+
const trustComponents = executableComponents(root, entry)
|
|
126
|
+
if (typeof options.trust === "boolean" && trustComponents.length) {
|
|
127
|
+
if (options.trust) grantEntry(entry, trustComponents)
|
|
128
|
+
else denyEntry(entry)
|
|
129
|
+
}
|
|
130
|
+
// the materializer reads the registry from disk, so the trust decision
|
|
131
|
+
// must be saved before links are made (spec 07)
|
|
132
|
+
saveRegistry(registry)
|
|
133
|
+
const report = materialize(name, root, { enabled: enabledPlugins(entry, root) })
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
url: parsed.url,
|
|
137
|
+
dir,
|
|
138
|
+
root,
|
|
139
|
+
mode,
|
|
140
|
+
plugins: plugins.map((plugin) => ({ name: plugin.name, components: plugin.components })),
|
|
141
|
+
warnings: discovered.warnings,
|
|
142
|
+
report,
|
|
143
|
+
trustComponents,
|
|
144
|
+
wasV1,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// spec 05 remove: full teardown — links, skills.paths entry, ocm-- mcp keys,
|
|
149
|
+
// the clone (never a local directory), the registry record
|
|
150
|
+
export function removeMarketplace(name) {
|
|
151
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
152
|
+
const entry = registry.marketplaces[name]
|
|
153
|
+
if (!entry) {
|
|
154
|
+
throw new Error(`marketplace "${name}" not found (ocm list)`)
|
|
155
|
+
}
|
|
156
|
+
const warnings = []
|
|
157
|
+
removeLinksFor(name, entry.dir)
|
|
158
|
+
const skillsWarning = setSkillsPath(join(LINKS_DIR, name, "skills"), false)
|
|
159
|
+
if (skillsWarning) warnings.push(skillsWarning)
|
|
160
|
+
// collision records never materialized, so their mcp keys are not ours to drop
|
|
161
|
+
const owned = Object.entries(entry.plugins).filter(([, plugin]) => !plugin.collision)
|
|
162
|
+
const mcpWarning = removeMcpKeys(owned.map(([pluginName]) => pluginName))
|
|
163
|
+
if (mcpWarning) warnings.push(mcpWarning)
|
|
164
|
+
// `local === false` rather than `!local`: an entry missing the field must
|
|
165
|
+
// never be treated as ocm-managed and deleted
|
|
166
|
+
if (entry.local === false) {
|
|
167
|
+
rmSync(entry.dir, { recursive: true, force: true })
|
|
168
|
+
}
|
|
169
|
+
delete registry.marketplaces[name]
|
|
170
|
+
saveRegistry(registry)
|
|
171
|
+
return {
|
|
172
|
+
name,
|
|
173
|
+
owned: owned.map(([pluginName, plugin]) => ({ name: pluginName, components: plugin.components })),
|
|
174
|
+
warnings,
|
|
175
|
+
wasV1,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// spec 08: pinning is branch- and tag-following, never commit-freezing.
|
|
180
|
+
// The ref is validated by fetching it before it is saved, so a typo fails
|
|
181
|
+
// immediately rather than breaking the next unattended sync. A null ref
|
|
182
|
+
// clears the pin.
|
|
183
|
+
export async function pinMarketplace(name, ref) {
|
|
184
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
185
|
+
const entry = registry.marketplaces[name]
|
|
186
|
+
if (!entry) throw new Error(`marketplace "${name}" not found (ocm list)`)
|
|
187
|
+
if (entry.local) throw new Error(`marketplace "${name}" is local; nothing to pin`)
|
|
188
|
+
if (ref === null) {
|
|
189
|
+
entry.ref = null
|
|
190
|
+
saveRegistry(registry)
|
|
191
|
+
return { name, ref: null, cleared: true, wasV1 }
|
|
192
|
+
}
|
|
193
|
+
if (!ref) throw new Error(`missing ref (ocm pin <name> <ref>)`)
|
|
194
|
+
const fetch = await git(["fetch", "--depth", "1", "origin", ref], entry.dir)
|
|
195
|
+
if (!fetch.ok) throw new Error(`cannot pin "${name}" to "${ref}": ${fetch.stderr || fetch.stdout}`)
|
|
196
|
+
entry.ref = ref
|
|
197
|
+
saveRegistry(registry)
|
|
198
|
+
return { name, ref, cleared: false, wasV1 }
|
|
199
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, rmdirSync } from "node:fs"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { setSkillsPath } from "./config.js"
|
|
5
|
+
import { discoverPlugins, PLUGIN_NAME_RE } from "./discovery.js"
|
|
6
|
+
import { gcTargets, isRenderedFile, link, mirror } from "./links.js"
|
|
7
|
+
import { syncMcp } from "./mcp.js"
|
|
8
|
+
import { LINKS_DIR, DISPLACED_DIR, OPENCODE_AGENTS_DIR, OPENCODE_COMMANDS_DIR, OPENCODE_PLUGINS_DIR } from "./paths.js"
|
|
9
|
+
import { readRegistry, isRecord } from "./registry.js"
|
|
10
|
+
import { approvedComponents, componentKey } from "./trust.js"
|
|
11
|
+
|
|
12
|
+
function managedDirs(dir, registry) {
|
|
13
|
+
const dirs = [dir]
|
|
14
|
+
for (const entry of Object.values(registry.marketplaces ?? {})) {
|
|
15
|
+
if (entry && typeof entry.dir === "string" && entry.dir && entry.dir !== dir) dirs.push(entry.dir)
|
|
16
|
+
}
|
|
17
|
+
return dirs.sort((a, b) => b.length - a.length)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function gitRevision(dir) {
|
|
21
|
+
try {
|
|
22
|
+
const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: dir, timeout: 5000, encoding: "utf8" })
|
|
23
|
+
if (result.status === 0) return result.stdout.trim()
|
|
24
|
+
} catch {}
|
|
25
|
+
return "unknown"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `commands` and `command` are both opencode-valid; a name resolvable in
|
|
29
|
+
// both is a clash we refuse rather than guess
|
|
30
|
+
function resolveSource(pluginDir, dirs, name, ctx, plugin) {
|
|
31
|
+
const found = dirs.map((d) => join(pluginDir, d, name)).filter((p) => existsSync(p))
|
|
32
|
+
if (found.length > 1) {
|
|
33
|
+
ctx.warnings.push(`skipped ${plugin}:${name}: found in both ${dirs[0]} and ${dirs[1]}`)
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
return found[0]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// the single transform: `name: <value>` becomes `name: "<plugin>:<value>"`;
|
|
40
|
+
// everything else in the file is preserved byte-for-byte
|
|
41
|
+
function renderSkillMd(content, plugin) {
|
|
42
|
+
if (!content.startsWith("---\n")) return null
|
|
43
|
+
const close = content.indexOf("\n---\n", 3)
|
|
44
|
+
if (close === -1) return null
|
|
45
|
+
const frontmatter = content.slice(4, close)
|
|
46
|
+
const match = frontmatter.match(/^name:[^\n]*/m)
|
|
47
|
+
if (!match) return null
|
|
48
|
+
const value = match[0].slice(5).trim().replace(/^["']|["']$/g, "")
|
|
49
|
+
if (!value) return null
|
|
50
|
+
const renamed = `name: "${plugin}:${value}"`
|
|
51
|
+
const updated = frontmatter.slice(0, match.index) + renamed + frontmatter.slice(match.index + match[0].length)
|
|
52
|
+
return content.slice(0, 4) + updated + content.slice(close)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function removeLegacyContainers(name) {
|
|
56
|
+
rmSync(join(OPENCODE_COMMANDS_DIR, `ocm--${name}`), { recursive: true, force: true })
|
|
57
|
+
rmSync(join(OPENCODE_AGENTS_DIR, `ocm--${name}`), { recursive: true, force: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function materialize(name, dir, options = {}) {
|
|
61
|
+
const warnings = []
|
|
62
|
+
const counts = { command: 0, agent: 0, skill: 0, plugin: 0, mcp: 0 }
|
|
63
|
+
let created = 0
|
|
64
|
+
let removed = 0
|
|
65
|
+
let skipped = 0
|
|
66
|
+
|
|
67
|
+
removeLegacyContainers(name)
|
|
68
|
+
|
|
69
|
+
if (!existsSync(dir)) {
|
|
70
|
+
warnings.push(`marketplace "${name}" directory missing (${dir}), links left untouched`)
|
|
71
|
+
return { counts, created, removed, skipped, warnings }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const registry = readRegistry()
|
|
75
|
+
const entry = (registry.marketplaces ?? {})[name]
|
|
76
|
+
const approved = approvedComponents(dir, entry)
|
|
77
|
+
const revision = (entry && typeof entry.revision === "string" && entry.revision) || gitRevision(dir)
|
|
78
|
+
const ctx = {
|
|
79
|
+
name,
|
|
80
|
+
dir,
|
|
81
|
+
managed: managedDirs(dir, registry),
|
|
82
|
+
revision,
|
|
83
|
+
warnings,
|
|
84
|
+
force: options.force === true,
|
|
85
|
+
displacedDir: join(DISPLACED_DIR, new Date().toISOString().replace(/[:.]/g, "-")),
|
|
86
|
+
}
|
|
87
|
+
const enabled = options.enabled ?? null
|
|
88
|
+
// a plugin-scoped pass (ocm update <plugin>@<mp>) reconciles only the
|
|
89
|
+
// named plugin; every other plugin's links and mcp keys stay untouched
|
|
90
|
+
const only = options.plugin ?? null
|
|
91
|
+
const skillsDir = join(LINKS_DIR, name, "skills")
|
|
92
|
+
const desiredCommands = new Set()
|
|
93
|
+
const desiredAgents = new Set()
|
|
94
|
+
const desiredMirrors = new Set()
|
|
95
|
+
const desiredPluginLinks = new Set()
|
|
96
|
+
|
|
97
|
+
mkdirSync(OPENCODE_COMMANDS_DIR, { recursive: true })
|
|
98
|
+
mkdirSync(OPENCODE_AGENTS_DIR, { recursive: true })
|
|
99
|
+
mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true })
|
|
100
|
+
|
|
101
|
+
const discovered = discoverPlugins(dir)
|
|
102
|
+
const active = only === null ? discovered : discovered.filter((plugin) => plugin.name === only)
|
|
103
|
+
for (const plugin of active) {
|
|
104
|
+
if (enabled !== null && !enabled.has(plugin.name)) continue
|
|
105
|
+
if (!PLUGIN_NAME_RE.test(plugin.name)) {
|
|
106
|
+
warnings.push(`skipped plugin "${plugin.name}": name must match ${PLUGIN_NAME_RE}`)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
for (const file of plugin.components.command ?? []) {
|
|
110
|
+
const source = resolveSource(plugin.dir, ["commands", "command"], file, ctx, plugin.name)
|
|
111
|
+
if (!source) continue
|
|
112
|
+
counts.command += 1
|
|
113
|
+
const dest = `${plugin.name}:${file}`
|
|
114
|
+
desiredCommands.add(dest)
|
|
115
|
+
const status = link(source, join(OPENCODE_COMMANDS_DIR, dest), ctx, plugin.name, file)
|
|
116
|
+
if (status === "created") created += 1
|
|
117
|
+
else if (status !== "ok") skipped += 1
|
|
118
|
+
}
|
|
119
|
+
for (const file of plugin.components.agent ?? []) {
|
|
120
|
+
const source = resolveSource(plugin.dir, ["agents", "agent"], file, ctx, plugin.name)
|
|
121
|
+
if (!source) continue
|
|
122
|
+
counts.agent += 1
|
|
123
|
+
const dest = `${plugin.name}:${file}`
|
|
124
|
+
desiredAgents.add(dest)
|
|
125
|
+
const status = link(source, join(OPENCODE_AGENTS_DIR, dest), ctx, plugin.name, file)
|
|
126
|
+
if (status === "created") created += 1
|
|
127
|
+
else if (status !== "ok") skipped += 1
|
|
128
|
+
}
|
|
129
|
+
for (const rel of plugin.components.skill ?? []) {
|
|
130
|
+
const sourceDir = resolveSource(plugin.dir, ["skills", "skill"], rel, ctx, plugin.name)
|
|
131
|
+
if (!sourceDir) continue
|
|
132
|
+
const skillMd = join(sourceDir, "SKILL.md")
|
|
133
|
+
let transformed = null
|
|
134
|
+
try {
|
|
135
|
+
transformed = renderSkillMd(readFileSync(skillMd, "utf8"), plugin.name)
|
|
136
|
+
} catch {}
|
|
137
|
+
if (transformed === null) {
|
|
138
|
+
warnings.push(`skipped ${skillMd}: no name in frontmatter`)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
counts.skill += 1
|
|
142
|
+
const mirrorName = `${plugin.name}--${rel.split("/").join("-")}`
|
|
143
|
+
desiredMirrors.add(mirrorName)
|
|
144
|
+
removed += mirror(sourceDir, join(skillsDir, mirrorName), { "SKILL.md": () => transformed }, ctx, plugin.name, rel)
|
|
145
|
+
}
|
|
146
|
+
for (const file of plugin.components.plugin ?? []) {
|
|
147
|
+
const source = resolveSource(plugin.dir, ["plugin", "plugins"], file, ctx, plugin.name)
|
|
148
|
+
if (!source) continue
|
|
149
|
+
counts.plugin += 1
|
|
150
|
+
const dest = `ocm--${plugin.name}--${file}`
|
|
151
|
+
if (!approved.get(componentKey("plugin", plugin.name, file))) {
|
|
152
|
+
warnings.push(`blocked (untrusted): ${plugin.name}:${file} not linked — run \`ocm trust ${name}\` to approve`)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
desiredPluginLinks.add(dest)
|
|
156
|
+
const status = link(source, join(OPENCODE_PLUGINS_DIR, dest), ctx, plugin.name, file)
|
|
157
|
+
if (status === "created") created += 1
|
|
158
|
+
else if (status !== "ok") skipped += 1
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// plugin names cannot contain ":", "--" or uppercase (PLUGIN_NAME_RE),
|
|
163
|
+
// so a name prefix never spans another plugin's entries
|
|
164
|
+
const scope = only === null ? undefined : `${only}:`
|
|
165
|
+
removed += gcTargets(OPENCODE_COMMANDS_DIR, desiredCommands, ctx, undefined, scope)
|
|
166
|
+
removed += gcTargets(OPENCODE_AGENTS_DIR, desiredAgents, ctx, undefined, scope)
|
|
167
|
+
removed += gcTargets(skillsDir, desiredMirrors, ctx, (path) => isRenderedFile(join(path, "SKILL.md")), only === null ? undefined : `${only}--`)
|
|
168
|
+
removed += gcTargets(OPENCODE_PLUGINS_DIR, desiredPluginLinks, ctx, undefined, only === null ? undefined : `ocm--${only}--`)
|
|
169
|
+
|
|
170
|
+
counts.mcp += syncMcp(active, dir, entry, enabled, approved, warnings)
|
|
171
|
+
|
|
172
|
+
// a scoped pass never unregisters the skills path: other plugins'
|
|
173
|
+
// rendered skills may still live there
|
|
174
|
+
if (only === null || counts.skill > 0) {
|
|
175
|
+
const warning = setSkillsPath(skillsDir, counts.skill > 0)
|
|
176
|
+
if (warning) warnings.push(warning)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { counts, created, removed, skipped, warnings }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// the enabled set a caller drives materialize with: registry plugins that
|
|
183
|
+
// are not disabled, plus discovered-but-unregistered ones in auto mode
|
|
184
|
+
export function enabledPlugins(entry, dir) {
|
|
185
|
+
if (!entry || typeof entry.dir !== "string" || !entry.dir) return null
|
|
186
|
+
const registered = entry.plugins ?? {}
|
|
187
|
+
const enabled = new Set()
|
|
188
|
+
for (const [name, plugin] of Object.entries(registered)) {
|
|
189
|
+
if (plugin && plugin.enabled !== false) enabled.add(name)
|
|
190
|
+
}
|
|
191
|
+
if (entry.mode !== "explicit") {
|
|
192
|
+
// a name some marketplace already provides is never auto-installed
|
|
193
|
+
// here: it would displace the incumbent's links (spec 04)
|
|
194
|
+
const taken = new Set()
|
|
195
|
+
for (const other of Object.values(readRegistry().marketplaces ?? {})) {
|
|
196
|
+
if (isRecord(other?.plugins)) for (const name of Object.keys(other.plugins)) taken.add(name)
|
|
197
|
+
}
|
|
198
|
+
for (const plugin of discoverPlugins(dir)) {
|
|
199
|
+
if (!(plugin.name in registered) && !taken.has(plugin.name)) enabled.add(plugin.name)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return enabled
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function removeLinksFor(name, marketplaceDir) {
|
|
206
|
+
removeLegacyContainers(name)
|
|
207
|
+
const ctx = { name, dir: marketplaceDir, managed: [], revision: null, warnings: [] }
|
|
208
|
+
gcTargets(OPENCODE_COMMANDS_DIR, new Set(), ctx)
|
|
209
|
+
gcTargets(OPENCODE_AGENTS_DIR, new Set(), ctx)
|
|
210
|
+
gcTargets(OPENCODE_PLUGINS_DIR, new Set(), ctx)
|
|
211
|
+
const skillsDir = join(LINKS_DIR, name, "skills")
|
|
212
|
+
gcTargets(skillsDir, new Set(), ctx, (path) => isRenderedFile(join(path, "SKILL.md")))
|
|
213
|
+
// prune the cache dirs only when nothing unowned is left in them
|
|
214
|
+
try {
|
|
215
|
+
rmdirSync(skillsDir)
|
|
216
|
+
rmdirSync(dirname(skillsDir))
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
package/loader/mcp.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { mcpSourceFile, PLUGIN_NAME_RE } from "./discovery.js"
|
|
3
|
+
import { OPENCODE_CONFIG_FILE, OPENCODE_DIR } from "./paths.js"
|
|
4
|
+
import { isRecord } from "./registry.js"
|
|
5
|
+
import { componentKey } from "./trust.js"
|
|
6
|
+
|
|
7
|
+
// desired keys are ocm--<plugin>--<server>; every other key in the mcp object
|
|
8
|
+
// is the user's and survives byte-identically outside the keys ocm owns
|
|
9
|
+
function applyMcpKeys(desired, prefixes) {
|
|
10
|
+
let raw
|
|
11
|
+
try {
|
|
12
|
+
raw = readFileSync(OPENCODE_CONFIG_FILE, "utf8")
|
|
13
|
+
} catch {}
|
|
14
|
+
let config = {}
|
|
15
|
+
if (raw !== undefined) {
|
|
16
|
+
try {
|
|
17
|
+
config = JSON.parse(raw)
|
|
18
|
+
} catch {
|
|
19
|
+
return `skipped ${OPENCODE_CONFIG_FILE}: not valid JSON, left untouched`
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (!isRecord(config)) return `skipped ${OPENCODE_CONFIG_FILE}: not a JSON object`
|
|
23
|
+
if (config.mcp !== undefined && !isRecord(config.mcp)) {
|
|
24
|
+
return `skipped ${OPENCODE_CONFIG_FILE}: "mcp" is not an object`
|
|
25
|
+
}
|
|
26
|
+
const mcp = isRecord(config.mcp) ? config.mcp : {}
|
|
27
|
+
let changed = false
|
|
28
|
+
for (const key of Object.keys(mcp)) {
|
|
29
|
+
if (prefixes.some((prefix) => key.startsWith(prefix)) && !desired.has(key)) {
|
|
30
|
+
delete mcp[key]
|
|
31
|
+
changed = true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const [key, value] of desired) {
|
|
35
|
+
if (JSON.stringify(mcp[key]) !== JSON.stringify(value)) {
|
|
36
|
+
mcp[key] = value
|
|
37
|
+
changed = true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!changed) return null
|
|
41
|
+
if (Object.keys(mcp).length) config.mcp = mcp
|
|
42
|
+
else delete config.mcp
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(OPENCODE_DIR, { recursive: true })
|
|
45
|
+
const tmp = `${OPENCODE_CONFIG_FILE}.tmp`
|
|
46
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`)
|
|
47
|
+
renameSync(tmp, OPENCODE_CONFIG_FILE)
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return `failed ${OPENCODE_CONFIG_FILE}: ${err instanceof Error ? err.message : String(err)}`
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// one pass over every discovered plugin: enabled plugins contribute desired
|
|
55
|
+
// keys for their approved servers, everything else only the prefix that
|
|
56
|
+
// scopes the stale keys removed for it (specs 06, 07)
|
|
57
|
+
export function syncMcp(plugins, dir, entry, enabled, approved, warnings) {
|
|
58
|
+
const desired = new Map()
|
|
59
|
+
const prefixes = []
|
|
60
|
+
let count = 0
|
|
61
|
+
for (const plugin of plugins) {
|
|
62
|
+
if (!PLUGIN_NAME_RE.test(plugin.name)) continue
|
|
63
|
+
prefixes.push(`ocm--${plugin.name}--`)
|
|
64
|
+
if (enabled !== null && !enabled.has(plugin.name)) continue
|
|
65
|
+
const file = mcpSourceFile(dir, entry, plugin)
|
|
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 {}
|
|
72
|
+
if (servers === null) {
|
|
73
|
+
warnings.push(`skipped ${file}: not a JSON object`)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
for (const [server, value] of Object.entries(servers)) {
|
|
77
|
+
if (!approved.get(componentKey("mcp", plugin.name, server))) {
|
|
78
|
+
warnings.push(`blocked (untrusted): ${plugin.name}:mcp/${server} not installed`)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
count += 1
|
|
82
|
+
desired.set(`ocm--${plugin.name}--${server}`, value)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const warning = applyMcpKeys(desired, prefixes)
|
|
86
|
+
if (warning) warnings.push(warning)
|
|
87
|
+
return count
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// spec 05 ocm remove: every ocm--<plugin> and ocm--<plugin>--* mcp key of
|
|
91
|
+
// these plugins goes, keeping the mcp object when the user has their own
|
|
92
|
+
// servers left in it. Returns a warning instead of printing — the core
|
|
93
|
+
// never prints.
|
|
94
|
+
export function removeMcpKeys(pluginNames) {
|
|
95
|
+
let raw
|
|
96
|
+
try {
|
|
97
|
+
raw = readFileSync(OPENCODE_CONFIG_FILE, "utf8")
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
let config
|
|
102
|
+
try {
|
|
103
|
+
config = JSON.parse(raw)
|
|
104
|
+
} catch {
|
|
105
|
+
return `${OPENCODE_CONFIG_FILE} is not valid JSON, left untouched`
|
|
106
|
+
}
|
|
107
|
+
if (!isRecord(config) || !isRecord(config.mcp)) return null
|
|
108
|
+
const mcp = config.mcp
|
|
109
|
+
const owned = Object.keys(mcp).filter((key) =>
|
|
110
|
+
pluginNames.some((name) => key === `ocm--${name}` || key.startsWith(`ocm--${name}--`)),
|
|
111
|
+
)
|
|
112
|
+
if (!owned.length) return null
|
|
113
|
+
for (const key of owned) delete mcp[key]
|
|
114
|
+
if (!Object.keys(mcp).length) delete config.mcp
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(OPENCODE_DIR, { recursive: true })
|
|
117
|
+
const tmp = `${OPENCODE_CONFIG_FILE}.tmp`
|
|
118
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`)
|
|
119
|
+
renameSync(tmp, OPENCODE_CONFIG_FILE)
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return `cannot write ${OPENCODE_CONFIG_FILE}: ${err instanceof Error ? err.message : String(err)}`
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { nameDisagreement } from "./manifest.js"
|
|
4
|
+
import { componentRoot } from "./marketplace.js"
|
|
5
|
+
import { enabledPlugins, materialize } from "./materialize.js"
|
|
6
|
+
import { loadRegistryForWrite, saveRegistry } from "./registry.js"
|
|
7
|
+
import { denyEntry, executableComponents, grantEntry } from "./trust.js"
|
|
8
|
+
|
|
9
|
+
// spec 05 argument resolution, shared by every verb that takes a plugin
|
|
10
|
+
export function resolvePlugin(registry, arg) {
|
|
11
|
+
const at = arg.indexOf("@")
|
|
12
|
+
let marketplace
|
|
13
|
+
let plugin
|
|
14
|
+
if (at !== -1) {
|
|
15
|
+
plugin = arg.slice(0, at)
|
|
16
|
+
marketplace = arg.slice(at + 1)
|
|
17
|
+
if (!plugin) throw new Error(`missing plugin name in "${arg}" (ocm list --all)`)
|
|
18
|
+
if (!marketplace) throw new Error(`missing marketplace name in "${arg}" (ocm list)`)
|
|
19
|
+
} else {
|
|
20
|
+
plugin = arg
|
|
21
|
+
const providers = Object.entries(registry.marketplaces).filter(([, entry]) => entry.plugins[plugin])
|
|
22
|
+
if (!providers.length) {
|
|
23
|
+
throw new Error(`plugin "${plugin}" not found in any marketplace (ocm add <url|path>, or ocm update)`)
|
|
24
|
+
}
|
|
25
|
+
if (providers.length > 1) {
|
|
26
|
+
const names = providers.map(([name]) => name).join(", ")
|
|
27
|
+
throw new Error(`plugin "${plugin}" is provided by more than one marketplace: ${names} (use ${plugin}@<marketplace>)`)
|
|
28
|
+
}
|
|
29
|
+
marketplace = providers[0][0]
|
|
30
|
+
}
|
|
31
|
+
const entry = registry.marketplaces[marketplace]
|
|
32
|
+
if (!entry) throw new Error(`marketplace "${marketplace}" not found (ocm list)`)
|
|
33
|
+
const record = entry.plugins[plugin]
|
|
34
|
+
if (!record) throw new Error(`plugin "${plugin}" not found in marketplace "${marketplace}" (ocm list --all)`)
|
|
35
|
+
if (!existsSync(join(componentRoot(entry), record.source))) {
|
|
36
|
+
throw new Error(`plugin "${plugin}" is registered but missing on disk in marketplace "${marketplace}" (run ocm update ${marketplace})`)
|
|
37
|
+
}
|
|
38
|
+
return { marketplace, plugin, entry }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// spec 05 install/uninstall: flip the record, save, reconcile links. A
|
|
42
|
+
// no-op flip saves nothing, so a repeat run writes nothing (idempotence).
|
|
43
|
+
export function setEnabled(arg, enabled, options = {}) {
|
|
44
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
45
|
+
const resolved = resolvePlugin(registry, arg)
|
|
46
|
+
const entry = registry.marketplaces[resolved.marketplace]
|
|
47
|
+
const record = entry.plugins[resolved.plugin]
|
|
48
|
+
const root = componentRoot(entry)
|
|
49
|
+
const current = enabled
|
|
50
|
+
? record.enabled && record.installedAt
|
|
51
|
+
: !record.enabled && record.installedAt === null
|
|
52
|
+
let saved = false
|
|
53
|
+
if (!current) {
|
|
54
|
+
record.enabled = enabled
|
|
55
|
+
record.installedAt = enabled ? new Date().toISOString() : null
|
|
56
|
+
saveRegistry(registry)
|
|
57
|
+
saved = true
|
|
58
|
+
}
|
|
59
|
+
const report = materialize(resolved.marketplace, root, {
|
|
60
|
+
enabled: enabledPlugins(entry, root),
|
|
61
|
+
force: options.force === true,
|
|
62
|
+
})
|
|
63
|
+
return {
|
|
64
|
+
marketplace: resolved.marketplace,
|
|
65
|
+
plugin: resolved.plugin,
|
|
66
|
+
components: record.components,
|
|
67
|
+
disagreement: nameDisagreement(join(root, record.source), resolved.plugin),
|
|
68
|
+
wasV1: wasV1 && saved,
|
|
69
|
+
report,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function grantTrust(name) {
|
|
74
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
75
|
+
const entry = registry.marketplaces[name]
|
|
76
|
+
if (!entry) throw new Error(`marketplace "${name}" not found (ocm list)`)
|
|
77
|
+
const root = componentRoot(entry)
|
|
78
|
+
const components = executableComponents(root, entry)
|
|
79
|
+
if (!components.length) return { granted: false, report: null, wasV1: false }
|
|
80
|
+
grantEntry(entry, components)
|
|
81
|
+
saveRegistry(registry)
|
|
82
|
+
const report = materialize(name, root, { enabled: enabledPlugins(entry, root) })
|
|
83
|
+
return { granted: true, report, wasV1 }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// idempotent: a second deny writes nothing (spec 07)
|
|
87
|
+
export function denyTrust(name) {
|
|
88
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
89
|
+
const entry = registry.marketplaces[name]
|
|
90
|
+
if (!entry) throw new Error(`marketplace "${name}" not found (ocm list)`)
|
|
91
|
+
let saved = false
|
|
92
|
+
if (entry.trust.code !== "denied" || entry.trustPending) {
|
|
93
|
+
denyEntry(entry)
|
|
94
|
+
saveRegistry(registry)
|
|
95
|
+
saved = true
|
|
96
|
+
}
|
|
97
|
+
const root = componentRoot(entry)
|
|
98
|
+
const report = materialize(name, root, { enabled: enabledPlugins(entry, root) })
|
|
99
|
+
return { report, wasV1: wasV1 && saved }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { denyTrust as revokeTrust }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// namespace import, not named bindings: the loader must link even when
|
|
2
|
+
// core.js exports less than this file uses, so a partially updated core
|
|
3
|
+
// degrades the hook rather than the whole loader
|
|
4
|
+
import * as core from "../ocm/core.js"
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
id: "ocm-loader",
|
|
8
|
+
server: async () => {
|
|
9
|
+
void core.syncAll({ reason: "startup" }).catch(() => {})
|
|
10
|
+
return {
|
|
11
|
+
// spec 11: command bodies reference ${OCM_PLUGIN_ROOT}/plugins/<name>/…
|
|
12
|
+
// and Claude Code's ${CLAUDE_PLUGIN_ROOT}; both point at the
|
|
13
|
+
// marketplace root
|
|
14
|
+
"shell.env": async (_input, output) => {
|
|
15
|
+
if (output && typeof output.env === "object" && output.env !== null) {
|
|
16
|
+
Object.assign(output.env, core.pluginRootEnv())
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
}
|
package/loader/paths.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
export const HOME = homedir()
|
|
5
|
+
export const OPENCODE_DIR = join(HOME, ".config", "opencode")
|
|
6
|
+
export const OPENCODE_CONFIG_FILE = join(OPENCODE_DIR, "opencode.json")
|
|
7
|
+
export const OPENCODE_COMMANDS_DIR = join(OPENCODE_DIR, "commands")
|
|
8
|
+
export const OPENCODE_AGENTS_DIR = join(OPENCODE_DIR, "agents")
|
|
9
|
+
export const OPENCODE_PLUGINS_DIR = join(OPENCODE_DIR, "plugins")
|
|
10
|
+
export const OCM_DIR = join(OPENCODE_DIR, "ocm")
|
|
11
|
+
export const CACHE_DIR = join(HOME, ".cache", "ocm")
|
|
12
|
+
export const MARKETPLACES_DIR = join(CACHE_DIR, "marketplaces")
|
|
13
|
+
export const LINKS_DIR = join(CACHE_DIR, "links")
|
|
14
|
+
export const DISPLACED_DIR = join(CACHE_DIR, "displaced")
|
|
15
|
+
export const REGISTRY_FILE = join(OCM_DIR, "registry.json")
|
|
16
|
+
// pre-01 layout; read as a fallback for one release
|
|
17
|
+
export const LEGACY_REGISTRY_FILE = join(OPENCODE_PLUGINS_DIR, "ocm-registry.json")
|
|
18
|
+
export const STAMP_FILE = join(CACHE_DIR, "last-sync.json")
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SYNC_INTERVAL_MS = 60 * 60 * 1000
|