@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,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
+ }
@@ -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