@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
package/src/loader.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { dirname, join } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { OCM_DIR, OCM_LEGACY_REGISTRY_FILE, OCM_LOADER_NAME, OPENCODE_GLOBAL_DIR, OPENCODE_PLUGINS_DIR } from "./paths"
6
+
7
+ const TUI_CONFIG_FILE = join(OPENCODE_GLOBAL_DIR, "tui.json")
8
+ const TUI_PLUGIN_ENTRY = "./ocm/ui.js"
9
+ const LEGACY_TUI_PLUGIN_ENTRY = "./plugins/ocm-ui.js"
10
+ const LEGACY_PLUGIN_FILES = ["ocm-core.js", "ocm-ui.js", "ocm-core.d.ts"]
11
+
12
+ // only a subset proves a candidate is the loader source dir; the full install
13
+ // list is derived from the directory itself (spec 01, Installation set)
14
+ const REQUIRED_LOADER_FILES = [OCM_LOADER_NAME, "core.js", "ui.js"]
15
+
16
+ function loaderSourceDir(): string {
17
+ const here = dirname(fileURLToPath(import.meta.url))
18
+ const candidates = [
19
+ join(here, "..", "..", "loader"),
20
+ join(here, "..", "loader"),
21
+ join(here, "loader"),
22
+ here,
23
+ ]
24
+ for (const candidate of candidates) {
25
+ if (REQUIRED_LOADER_FILES.every((name) => existsSync(join(candidate, name)))) return candidate
26
+ }
27
+ throw new Error(`loader files not found (${REQUIRED_LOADER_FILES.join(", ")})`)
28
+ }
29
+
30
+ // the installed set is the loader directory by definition: ocm-loader.js to
31
+ // plugins/, every other *.js / *.d.ts to ocm/; the filter keeps editor junk
32
+ // (e.g. .DS_Store) out of the install
33
+ function loaderFiles(sourceDir: string): { source: string; target: string }[] {
34
+ return readdirSync(sourceDir)
35
+ .filter((name) => /\.(js|d\.ts)$/.test(name))
36
+ .sort()
37
+ .map((name) => ({
38
+ source: name,
39
+ target: join(name === OCM_LOADER_NAME ? OPENCODE_PLUGINS_DIR : OCM_DIR, name),
40
+ }))
41
+ }
42
+
43
+ function packageVersion(): string {
44
+ const here = dirname(fileURLToPath(import.meta.url))
45
+ const parsed = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { version?: unknown }
46
+ return typeof parsed.version === "string" ? parsed.version : "0"
47
+ }
48
+
49
+ function stamped(source: string): string {
50
+ const content = readFileSync(source, "utf8")
51
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
52
+ return `${content.trimEnd()}\n// ocm-version: ${packageVersion()} ${hash}\n`
53
+ }
54
+
55
+ function readTuiConfig(): Record<string, unknown> | undefined {
56
+ if (!existsSync(TUI_CONFIG_FILE)) return {}
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(TUI_CONFIG_FILE, "utf8")) as unknown
59
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>
60
+ } catch {}
61
+ return undefined
62
+ }
63
+
64
+ function writeTuiConfig(config: Record<string, unknown>): void {
65
+ const tmp = `${TUI_CONFIG_FILE}.ocm-tmp`
66
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`)
67
+ renameSync(tmp, TUI_CONFIG_FILE)
68
+ }
69
+
70
+ function warnTuiManual(reason: string): void {
71
+ console.error(`warning: ${TUI_CONFIG_FILE} ${reason}, left untouched`)
72
+ console.error(`warning: add "${TUI_PLUGIN_ENTRY}" to its "plugin" array manually for the /ocm TUI command`)
73
+ }
74
+
75
+ function ensureTuiPluginEntry(): void {
76
+ const config = readTuiConfig()
77
+ if (!config) {
78
+ warnTuiManual("is not valid JSON or not a JSON object")
79
+ return
80
+ }
81
+ const plugin = config.plugin
82
+ if (plugin !== undefined && !Array.isArray(plugin)) {
83
+ warnTuiManual('has a "plugin" key that is not an array')
84
+ return
85
+ }
86
+ const entries = Array.isArray(plugin) ? plugin : []
87
+ if (entries.includes(TUI_PLUGIN_ENTRY)) return
88
+ config.plugin = [...entries, TUI_PLUGIN_ENTRY]
89
+ writeTuiConfig(config)
90
+ }
91
+
92
+ function removeTuiPluginEntry(): void {
93
+ const config = readTuiConfig()
94
+ if (!config) return
95
+ const plugin = config.plugin
96
+ if (!Array.isArray(plugin)) return
97
+ const filtered = plugin.filter((entry) => entry !== TUI_PLUGIN_ENTRY)
98
+ if (filtered.length === plugin.length) return
99
+ config.plugin = filtered
100
+ writeTuiConfig(config)
101
+ }
102
+
103
+ function rewriteLegacyTuiPluginEntry(): void {
104
+ const config = readTuiConfig()
105
+ if (!config) {
106
+ warnTuiManual("is not valid JSON or not a JSON object")
107
+ return
108
+ }
109
+ const plugin = config.plugin
110
+ if (!Array.isArray(plugin) || !plugin.includes(LEGACY_TUI_PLUGIN_ENTRY)) return
111
+ config.plugin = plugin.map((entry) => (entry === LEGACY_TUI_PLUGIN_ENTRY ? TUI_PLUGIN_ENTRY : entry))
112
+ writeTuiConfig(config)
113
+ }
114
+
115
+ function installFiles(sourceDir: string): void {
116
+ if (existsSync(OCM_DIR) && !statSync(OCM_DIR).isDirectory()) {
117
+ throw new Error(`${OCM_DIR} exists but is not a directory; remove it or move it aside, then re-run ocm init`)
118
+ }
119
+ mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true })
120
+ mkdirSync(OCM_DIR, { recursive: true })
121
+ for (const file of loaderFiles(sourceDir)) {
122
+ const content = stamped(join(sourceDir, file.source))
123
+ let current: string | undefined
124
+ try {
125
+ current = readFileSync(file.target, "utf8")
126
+ } catch {
127
+ current = undefined
128
+ }
129
+ if (current === content) continue
130
+ writeFileSync(file.target, content)
131
+ }
132
+ ensureTuiPluginEntry()
133
+ }
134
+
135
+ // spec 12 doctor: the installed state of every loader file, by version
136
+ // comment — a stale core silently no-ops, so drift is worth naming
137
+ export interface LoaderFileStatus {
138
+ file: string
139
+ state: "current" | "stale" | "missing"
140
+ }
141
+
142
+ export function loaderStatus(): LoaderFileStatus[] {
143
+ const sourceDir = loaderSourceDir()
144
+ return loaderFiles(sourceDir).map((file) => {
145
+ let state: LoaderFileStatus["state"]
146
+ try {
147
+ state = readFileSync(file.target, "utf8") === stamped(join(sourceDir, file.source)) ? "current" : "stale"
148
+ } catch {
149
+ state = "missing"
150
+ }
151
+ return { file: file.source, state }
152
+ })
153
+ }
154
+
155
+ export function migrateLegacyLayout(): void {
156
+ const triggered = ["ocm-core.js", "ocm-ui.js"].some((name) => existsSync(join(OPENCODE_PLUGINS_DIR, name)))
157
+ if (!triggered) return
158
+ mkdirSync(OCM_DIR, { recursive: true })
159
+ if (existsSync(OCM_LEGACY_REGISTRY_FILE)) {
160
+ renameSync(OCM_LEGACY_REGISTRY_FILE, join(OCM_DIR, "registry.json"))
161
+ }
162
+ for (const name of LEGACY_PLUGIN_FILES) {
163
+ rmSync(join(OPENCODE_PLUGINS_DIR, name), { force: true })
164
+ }
165
+ rewriteLegacyTuiPluginEntry()
166
+ installFiles(loaderSourceDir())
167
+ // spec 01 migration step 5 pins this line to stdout
168
+ console.log(`migrated ocm registry to ${join(OCM_DIR, "registry.json")}`)
169
+ }
170
+
171
+ export function installLoader(): void {
172
+ migrateLegacyLayout()
173
+ installFiles(loaderSourceDir())
174
+ console.error(`installed auto-sync loader (${join(OPENCODE_PLUGINS_DIR, OCM_LOADER_NAME)})`)
175
+ console.error(`installed TUI plugin (/ocm in the opencode TUI, restart opencode to activate)`)
176
+ }
177
+
178
+ export function uninstallLoader(): void {
179
+ for (const name of [OCM_LOADER_NAME, ...LEGACY_PLUGIN_FILES]) {
180
+ rmSync(join(OPENCODE_PLUGINS_DIR, name), { force: true })
181
+ }
182
+ rmSync(OCM_DIR, { recursive: true, force: true })
183
+ removeTuiPluginEntry()
184
+ console.log("removed auto-sync loader")
185
+ }
@@ -0,0 +1,189 @@
1
+ // Hand-rolled validator for marketplace.json / plugin.json / mcp.json,
2
+ // implementing the same rules as schema/*-v1.json (spec 12: no runtime
3
+ // dependency; the schema files exist for editors).
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { join, relative } from "node:path"
6
+ import { readRegistry } from "../loader/core.js"
7
+ import { error, warning, type Finding } from "./findings"
8
+
9
+ export const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
10
+
11
+ const STRING_FIELDS = ["description", "version", "category", "homepage", "repository", "license"]
12
+ const ARRAY_FIELDS = ["tags", "keywords"]
13
+
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value)
16
+ }
17
+
18
+ // bun's JSON.parse errors carry line/column properties but no position in
19
+ // the message itself
20
+ function jsonPosition(err: unknown): string {
21
+ const { line, column } = err as { line?: unknown; column?: unknown }
22
+ return typeof line === "number" && typeof column === "number" ? `${line}:${column}` : "0"
23
+ }
24
+
25
+ function parseJson(file: string, rel: string, findings: Finding[]): Record<string, unknown> | undefined {
26
+ let parsed: unknown
27
+ try {
28
+ parsed = JSON.parse(readFileSync(file, "utf8"))
29
+ } catch (err) {
30
+ findings.push(error(`${rel}: invalid JSON at position ${jsonPosition(err)}`))
31
+ return undefined
32
+ }
33
+ if (!isRecord(parsed)) {
34
+ findings.push(error(`${rel}: must be a JSON object`))
35
+ return undefined
36
+ }
37
+ return parsed
38
+ }
39
+
40
+ function lintFields(rel: string, record: Record<string, unknown>, findings: Finding[]): void {
41
+ for (const key of STRING_FIELDS) {
42
+ if (record[key] !== undefined && typeof record[key] !== "string") {
43
+ findings.push(error(`${rel}: "${key}" must be a string`))
44
+ }
45
+ }
46
+ for (const key of ARRAY_FIELDS) {
47
+ const value = record[key]
48
+ if (value !== undefined && (!Array.isArray(value) || !value.every((item) => typeof item === "string"))) {
49
+ findings.push(error(`${rel}: "${key}" must be an array of strings`))
50
+ }
51
+ }
52
+ }
53
+
54
+ function lintName(rel: string, name: string, pluginName: string | undefined, findings: Finding[]): void {
55
+ if (!NAME_RE.test(name)) {
56
+ findings.push(error(`${rel}: name "${name}" must match ${NAME_RE}`))
57
+ } else if (name.length > 64) {
58
+ findings.push(error(`${rel}: name "${name}" is longer than 64 characters`))
59
+ } else if (pluginName !== undefined && name !== pluginName) {
60
+ findings.push(error(`${rel}: name "${name}" disagrees with the directory name "${pluginName}"`))
61
+ }
62
+ }
63
+
64
+ function lintSource(rel: string, entry: Record<string, unknown>, root: string, findings: Finding[]): void {
65
+ const name = typeof entry.name === "string" ? entry.name : "(unnamed)"
66
+ const source = entry.source
67
+ if (typeof source !== "string") {
68
+ findings.push(error(`${rel}: plugins[] entry "${name}" needs a "source"`))
69
+ return
70
+ }
71
+ if (source.split("/").includes("..")) {
72
+ findings.push(error(`${rel}: plugins[] entry "${name}" source "${source}" escapes the marketplace`))
73
+ } else if (!source.startsWith("./")) {
74
+ findings.push(error(`${rel}: plugins[] entry "${name}" source "${source}" must be ./-relative`))
75
+ } else if (!existsSync(join(root, source.slice(2)))) {
76
+ findings.push(error(`${rel}: plugins[] entry "${name}" source "${source}" does not resolve`))
77
+ }
78
+ }
79
+
80
+ // the plugins[]-entry fields beyond the shared string/array ones, per
81
+ // schema/marketplace-v1.json
82
+ function lintEntryFields(rel: string, entry: Record<string, unknown>, findings: Finding[]): void {
83
+ if (entry.author !== undefined && !isRecord(entry.author)) {
84
+ findings.push(error(`${rel}: "author" must be an object`))
85
+ }
86
+ if (entry.defaultEnabled !== undefined && typeof entry.defaultEnabled !== "boolean") {
87
+ findings.push(error(`${rel}: "defaultEnabled" must be a boolean`))
88
+ }
89
+ if (entry.mcpServers !== undefined && (typeof entry.mcpServers !== "string" || !entry.mcpServers.startsWith("./"))) {
90
+ findings.push(error(`${rel}: "mcpServers" must be a ./-relative path`))
91
+ }
92
+ }
93
+
94
+ function lintPlugins(
95
+ rel: string,
96
+ plugins: unknown,
97
+ root: string,
98
+ manifest: MarketplaceManifest,
99
+ findings: Finding[],
100
+ ): void {
101
+ if (!Array.isArray(plugins)) {
102
+ findings.push(error(`${rel}: "plugins" must be an array`))
103
+ return
104
+ }
105
+ for (const entry of plugins) {
106
+ if (!isRecord(entry)) {
107
+ findings.push(error(`${rel}: every plugins[] entry must be an object`))
108
+ continue
109
+ }
110
+ lintFields(rel, entry, findings)
111
+ lintEntryFields(rel, entry, findings)
112
+ if (typeof entry.name !== "string") {
113
+ findings.push(error(`${rel}: plugins[] entry needs a "name"`))
114
+ } else {
115
+ lintName(rel, entry.name, undefined, findings)
116
+ manifest.entries.set(entry.name, entry)
117
+ }
118
+ lintSource(rel, entry, root, findings)
119
+ }
120
+ }
121
+
122
+ export interface MarketplaceManifest {
123
+ name?: string
124
+ entries: Map<string, Record<string, unknown>>
125
+ }
126
+
127
+ export function lintMarketplaceJson(root: string, findings: Finding[]): MarketplaceManifest {
128
+ const manifest: MarketplaceManifest = { entries: new Map() }
129
+ const file = join(root, "marketplace.json")
130
+ if (!existsSync(file)) return manifest
131
+ const rel = relative(root, file)
132
+ const parsed = parseJson(file, rel, findings)
133
+ if (!parsed) return manifest
134
+ lintFields(rel, parsed, findings)
135
+ if (parsed.owner !== undefined && !isRecord(parsed.owner)) {
136
+ findings.push(error(`${rel}: "owner" must be an object`))
137
+ }
138
+ if (parsed.renames !== undefined &&
139
+ (!isRecord(parsed.renames) || !Object.values(parsed.renames).every((value) => typeof value === "string" || value === null))) {
140
+ findings.push(error(`${rel}: "renames" must be an object of string or null`))
141
+ }
142
+ if (parsed.name !== undefined && typeof parsed.name !== "string") {
143
+ findings.push(error(`${rel}: "name" must be a string`))
144
+ }
145
+ if (typeof parsed.name === "string") {
146
+ manifest.name = parsed.name
147
+ if (readRegistry().marketplaces[parsed.name]) {
148
+ findings.push(warning(`${rel}: name "${parsed.name}" is already added on this machine`))
149
+ }
150
+ }
151
+ if (parsed.plugins !== undefined) lintPlugins(rel, parsed.plugins, root, manifest, findings)
152
+ return manifest
153
+ }
154
+
155
+ // returns the parsed record so the caller can compare versions across manifests
156
+ export function lintPluginJson(
157
+ root: string,
158
+ pluginDir: string,
159
+ pluginName: string,
160
+ findings: Finding[],
161
+ ): Record<string, unknown> | undefined {
162
+ const file = join(pluginDir, "plugin.json")
163
+ if (!existsSync(file)) return undefined
164
+ const rel = relative(root, file)
165
+ const parsed = parseJson(file, rel, findings)
166
+ if (!parsed) return undefined
167
+ lintFields(rel, parsed, findings)
168
+ if (parsed.author !== undefined && !isRecord(parsed.author)) {
169
+ findings.push(error(`${rel}: "author" must be an object`))
170
+ }
171
+ if (parsed.name !== undefined && typeof parsed.name !== "string") {
172
+ findings.push(error(`${rel}: "name" must be a string`))
173
+ }
174
+ if (typeof parsed.name === "string") lintName(rel, parsed.name, pluginName, findings)
175
+ return parsed
176
+ }
177
+
178
+ export function lintMcpJson(root: string, pluginDir: string, findings: Finding[]): void {
179
+ const file = join(pluginDir, "mcp.json")
180
+ if (!existsSync(file)) return
181
+ const rel = relative(root, file)
182
+ const parsed = parseJson(file, rel, findings)
183
+ if (!parsed) return
184
+ for (const [server, value] of Object.entries(parsed)) {
185
+ if (!isRecord(value) || value.type === undefined) {
186
+ findings.push(error(`${rel}: entry "${server}" is missing "type"`))
187
+ }
188
+ }
189
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,117 @@
1
+ // spec 13: the second half of the one-shot migration, run by every ocm command
2
+ // before dispatch. The file moves live in migrateLegacyLayout (spec 01) and
3
+ // stay byte-preserving so a programmatic installLoader keeps its spec 01
4
+ // semantics; this half upgrades what later specs changed in place — the
5
+ // registry schema (02), the sync stamp (08) and the skill link layout (03) —
6
+ // and is deliberately not called from installLoader.
7
+ import { existsSync, readFileSync, readdirSync, readlinkSync, rmSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { componentRoot, enabledPlugins, materialize } from "../loader/core.js"
10
+ import { OCM_LINKS_DIR, OCM_REGISTRY_FILE, OCM_STAMP_FILE, OPENCODE_AGENTS_DIR, OPENCODE_COMMANDS_DIR } from "./paths"
11
+ import { loadRegistry, loadRegistryForWrite, saveRegistry } from "./registry"
12
+ import { reportUpgrade, reportWarnings } from "./report"
13
+ import type { Registry } from "./types"
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value)
17
+ }
18
+
19
+ // registry version 1 → 2 on disk: normalization fills the v2 defaults and
20
+ // rewrites absolute sources marketplace-relative (spec 02)
21
+ function upgradeRegistry(): void {
22
+ const { registry, wasV1 } = loadRegistryForWrite()
23
+ if (!wasV1) return
24
+ saveRegistry(registry)
25
+ reportUpgrade(true)
26
+ }
27
+
28
+ // the pre-08 global throttle stamp folds into per-marketplace lastSync (spec 08)
29
+ function foldSyncStamp(): void {
30
+ let at: string | null = null
31
+ try {
32
+ const stamp: unknown = JSON.parse(readFileSync(OCM_STAMP_FILE, "utf8"))
33
+ if (isRecord(stamp) && typeof stamp.at === "string") at = stamp.at
34
+ } catch {
35
+ return
36
+ }
37
+ let raw: unknown
38
+ try {
39
+ raw = JSON.parse(readFileSync(OCM_REGISTRY_FILE, "utf8"))
40
+ } catch {
41
+ return
42
+ }
43
+ const entries = isRecord(raw) && isRecord(raw.marketplaces) ? Object.values(raw.marketplaces).filter(isRecord) : []
44
+ const open = entries.filter((entry) => entry.lastSync == null)
45
+ rmSync(OCM_STAMP_FILE, { force: true })
46
+ if (!at || !open.length) return
47
+ for (const entry of open) entry.lastSync = { at, ok: true, error: null }
48
+ saveRegistry(raw as Registry)
49
+ console.log(`folded ${OCM_STAMP_FILE} into per-marketplace lastSync`)
50
+ }
51
+
52
+ // a top-level symlink in the skills dir is the pre-spec-03 whole-dir form; it
53
+ // is ours iff it points into this marketplace (raw prefix compare — a realpath
54
+ // must never be compared against a non-canonical directory)
55
+ function legacySkillLinks(skillsDir: string, root: string): string[] {
56
+ let entries: string[]
57
+ try {
58
+ entries = readdirSync(skillsDir)
59
+ } catch {
60
+ return []
61
+ }
62
+ const legacy: string[] = []
63
+ for (const entry of entries) {
64
+ let target: string | undefined
65
+ try {
66
+ target = readlinkSync(join(skillsDir, entry))
67
+ } catch {
68
+ continue
69
+ }
70
+ if (target === root || target.startsWith(`${root}/`)) legacy.push(entry)
71
+ }
72
+ return legacy
73
+ }
74
+
75
+ // the old → new names for one plugin's relinked skills, read back from the
76
+ // rendered mirrors materialize just wrote
77
+ function skillMapping(skillsDir: string, plugin: string): string[] {
78
+ let entries: string[]
79
+ try {
80
+ entries = readdirSync(skillsDir)
81
+ } catch {
82
+ return []
83
+ }
84
+ const lines: string[] = []
85
+ for (const entry of entries) {
86
+ if (!entry.startsWith(`${plugin}--`)) continue
87
+ let name: string | undefined
88
+ try {
89
+ name = readFileSync(join(skillsDir, entry, "SKILL.md"), "utf8").match(/^name: "(.+)"$/m)?.[1]
90
+ } catch {}
91
+ if (name) lines.push(`relinked skill ${name.slice(plugin.length + 1)} -> ${name}`)
92
+ }
93
+ return lines.sort()
94
+ }
95
+
96
+ // whole-dir skill symlinks and ocm--<mp> containers both predate spec 03; one
97
+ // materialize pass — the same code path an ocm update runs — replaces them
98
+ // with the current layout
99
+ function relinkSkills(): void {
100
+ for (const [name, entry] of Object.entries(loadRegistry().marketplaces)) {
101
+ const root = componentRoot(entry)
102
+ const skillsDir = join(OCM_LINKS_DIR, name, "skills")
103
+ const legacy = legacySkillLinks(skillsDir, root)
104
+ const container =
105
+ existsSync(join(OPENCODE_COMMANDS_DIR, `ocm--${name}`)) || existsSync(join(OPENCODE_AGENTS_DIR, `ocm--${name}`))
106
+ if (!legacy.length && !container) continue
107
+ const links = materialize(name, root, { enabled: enabledPlugins(entry, root) })
108
+ reportWarnings(links.warnings.map((warning) => `${name}: ${warning}`))
109
+ for (const plugin of legacy) for (const line of skillMapping(skillsDir, plugin)) console.log(line)
110
+ }
111
+ }
112
+
113
+ export function migrateInstallation(): void {
114
+ upgradeRegistry()
115
+ foldSyncStamp()
116
+ relinkSkills()
117
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ export const HOME = homedir()
5
+
6
+ export const OPENCODE_GLOBAL_DIR = join(HOME, ".config", "opencode")
7
+ export const OPENCODE_GLOBAL_CONFIG = join(OPENCODE_GLOBAL_DIR, "opencode.json")
8
+ export const OPENCODE_COMMANDS_DIR = join(OPENCODE_GLOBAL_DIR, "commands")
9
+ export const OPENCODE_AGENTS_DIR = join(OPENCODE_GLOBAL_DIR, "agents")
10
+ export const OPENCODE_PLUGINS_DIR = join(OPENCODE_GLOBAL_DIR, "plugins")
11
+
12
+ export const OCM_CACHE_DIR = join(HOME, ".cache", "ocm")
13
+ export const OCM_MARKETPLACES_DIR = join(OCM_CACHE_DIR, "marketplaces")
14
+ export const OCM_LINKS_DIR = join(OCM_CACHE_DIR, "links")
15
+ // pre-08 global sync stamp, folded into per-marketplace lastSync by the migration
16
+ export const OCM_STAMP_FILE = join(OCM_CACHE_DIR, "last-sync.json")
17
+
18
+ export const OCM_DIR = join(OPENCODE_GLOBAL_DIR, "ocm")
19
+ export const OCM_REGISTRY_FILE = join(OCM_DIR, "registry.json")
20
+ export const OCM_LEGACY_REGISTRY_FILE = join(OPENCODE_PLUGINS_DIR, "ocm-registry.json")
21
+
22
+ export const OCM_LOADER_NAME = "ocm-loader.js"
package/src/probe.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { OPENCODE_GLOBAL_DIR } from "./paths"
6
+
7
+ // Ask the real opencode binary which of its plugins failed to load, keeping
8
+ // only errors attributable to ocm files. The probe runs against a throwaway
9
+ // copy of the config directory under a scratch HOME: opencode rewrites
10
+ // opencode.json on load (it adds $schema) and creates files under HOME, so
11
+ // probing the real directory would mutate the user's config. OCM_SYNC_DISABLE
12
+ // keeps the copied loader's startup sync inert as well. Never throws — a
13
+ // failed probe reports nothing, it does not take doctor down with it.
14
+ export function ocmPluginErrors(): string[] {
15
+ if (!existsSync(OPENCODE_GLOBAL_DIR)) return []
16
+ let scratch: string | undefined
17
+ try {
18
+ scratch = mkdtempSync(join(tmpdir(), "ocm-doctor-"))
19
+ const configDir = join(scratch, "config")
20
+ cpSync(OPENCODE_GLOBAL_DIR, configDir, { recursive: true })
21
+ const env: NodeJS.ProcessEnv = {
22
+ ...process.env,
23
+ HOME: join(scratch, "home"),
24
+ OPENCODE_CONFIG_DIR: configDir,
25
+ OCM_SYNC_DISABLE: "1",
26
+ }
27
+ // an XDG override would point opencode back at the real global config
28
+ delete env.XDG_CONFIG_HOME
29
+ const run = spawnSync("opencode", ["debug", "skill", "--print-logs", "--log-level", "ERROR"], {
30
+ env,
31
+ encoding: "utf8",
32
+ timeout: 180_000,
33
+ })
34
+ if (run.status !== 0) return []
35
+ const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`
36
+ return output
37
+ .split("\n")
38
+ .filter((line) => /level=ERROR.*failed to load plugin/.test(line))
39
+ .filter((line) => line.includes("/ocm--") || line.includes("/ocm-loader.js"))
40
+ .map((line) => line.trim())
41
+ } catch {
42
+ return []
43
+ } finally {
44
+ if (scratch !== undefined) {
45
+ // opencode leaves background writers in the scratch dir; a leftover
46
+ // temp directory is harmless, a crashed doctor is not
47
+ try {
48
+ rmSync(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
49
+ } catch {}
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,25 @@
1
+ import {
2
+ loadRegistryForWrite as coreLoadRegistryForWrite,
3
+ normalizeRegistry as coreNormalizeRegistry,
4
+ readRegistry,
5
+ saveRegistry as coreSaveRegistry,
6
+ } from "../loader/core.js"
7
+ import type { Registry } from "./types"
8
+
9
+ // thin facade over the core registry: the CLI keeps its typed surface, the
10
+ // canonical atomic save lives in the core (spec 10a)
11
+ export function loadRegistry(): Registry {
12
+ return readRegistry()
13
+ }
14
+
15
+ export function loadRegistryForWrite(): { registry: Registry; wasV1: boolean } {
16
+ return coreLoadRegistryForWrite()
17
+ }
18
+
19
+ export function normalizeRegistry(raw: unknown): Registry {
20
+ return coreNormalizeRegistry(raw)
21
+ }
22
+
23
+ export function saveRegistry(registry: Registry): void {
24
+ coreSaveRegistry(registry)
25
+ }
package/src/renames.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { incumbentMarketplace } from "./install"
2
+ import type { DiscoveredPlugin, MarketplaceEntry, MarketplacePlugin, Registry } from "./types"
3
+
4
+ export interface RenameResult {
5
+ renamed: { from: string; to: string }[]
6
+ removed: string[]
7
+ refused: { from: string; to: string; incumbent: string }[]
8
+ // rename targets refused for colliding with another marketplace's plugin
9
+ // name: excluded from registration so the incumbent keeps the name
10
+ excluded: Set<string>
11
+ // records a refusal kept at their old name, re-added after registration
12
+ kept: Record<string, MarketplacePlugin>
13
+ }
14
+
15
+ // spec 08 rename chains: walk each source while its target is itself a
16
+ // source. A walk that revisits a name is a cycle — reported by the caller
17
+ // and ignored, so its keys stay unresolved.
18
+ export function resolveChains(renames: Record<string, string | null>): {
19
+ resolved: Record<string, string | null>
20
+ cycles: string[][]
21
+ } {
22
+ const resolved: Record<string, string | null> = {}
23
+ const cycles: string[][] = []
24
+ const reported = new Set<string>()
25
+ for (const start of Object.keys(renames)) {
26
+ if (start in resolved) continue
27
+ const path: string[] = []
28
+ const visited = new Set<string>()
29
+ let current: string | null = start
30
+ let cyclic = false
31
+ while (current !== null && current in renames) {
32
+ if (visited.has(current)) {
33
+ const cycle = path.slice(path.indexOf(current))
34
+ const id = [...cycle].sort().join("\u0000")
35
+ if (!reported.has(id)) {
36
+ reported.add(id)
37
+ cycles.push(cycle)
38
+ }
39
+ cyclic = true
40
+ break
41
+ }
42
+ visited.add(current)
43
+ path.push(current)
44
+ current = renames[current] ?? null
45
+ }
46
+ if (!cyclic) for (const key of path) resolved[key] = current
47
+ }
48
+ return { resolved, cycles }
49
+ }
50
+
51
+ // applied after discovery, before registration: migrate records along the
52
+ // resolved renames, drop removals, refuse cross-marketplace collisions
53
+ export function applyRenames(
54
+ registry: Registry,
55
+ name: string,
56
+ entry: MarketplaceEntry,
57
+ discovered: Map<string, DiscoveredPlugin>,
58
+ resolved: Record<string, string | null>,
59
+ ): RenameResult {
60
+ const result: RenameResult = { renamed: [], removed: [], refused: [], excluded: new Set(), kept: {} }
61
+ for (const [from, to] of Object.entries(resolved)) {
62
+ const record = entry.plugins[from]
63
+ if (!record) continue
64
+ if (to === null) {
65
+ delete entry.plugins[from]
66
+ result.removed.push(from)
67
+ continue
68
+ }
69
+ const incumbent = incumbentMarketplace(registry, name, to)
70
+ if (incumbent) {
71
+ result.refused.push({ from, to, incumbent })
72
+ result.excluded.add(to)
73
+ result.kept[from] = record
74
+ continue
75
+ }
76
+ // the target is not shipped (yet): the record dangles rather than moves
77
+ if (!discovered.has(to)) continue
78
+ entry.plugins[to] = record
79
+ delete entry.plugins[from]
80
+ result.renamed.push({ from, to })
81
+ }
82
+ return result
83
+ }