@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,187 @@
1
+ // The marketplace flows of the /ocm TUI dialog (spec 10b): list,
2
+ // per-marketplace menu, add/remove/update/pin, and update-all.
3
+ import {
4
+ addMarketplace,
5
+ componentRoot,
6
+ enabledPlugins,
7
+ grantTrust,
8
+ materialize,
9
+ pinMarketplace,
10
+ pullRepo,
11
+ readRegistry,
12
+ removeMarketplace,
13
+ syncAll,
14
+ } from "./core.js"
15
+ import { NOTICE, alert, componentSummary, confirm, message, prompt, select, toast } from "./ui-dialog.js"
16
+ import { openMainMenu } from "./ui.js"
17
+ import { trustFlow, trustMessage, untrustFlow } from "./ui-trust.js"
18
+
19
+ function busy(api, text) {
20
+ api.ui.dialog.replace(() =>
21
+ api.ui.DialogSelect({ title: "ocm", options: [{ title: text, value: "busy" }], onSelect: () => {} }),
22
+ )
23
+ }
24
+
25
+ export function openMarketplaces(api) {
26
+ const options = Object.entries(readRegistry().marketplaces ?? {}).map(([name, entry]) => ({
27
+ title: name,
28
+ value: name,
29
+ description: entry.local ? `${entry.url} (local)` : entry.url,
30
+ }))
31
+ options.push(
32
+ { title: "Add", value: "add", description: "Add a marketplace from a URL or path" },
33
+ { title: "Back", value: "back", description: "Back to the main menu" },
34
+ )
35
+ select(api, {
36
+ title: "Marketplaces",
37
+ options,
38
+ onSelect: (option) => {
39
+ if (option.value === "add") addMarketplaceFlow(api, () => openMarketplaces(api))
40
+ else if (option.value === "back") openMainMenu(api)
41
+ else openMarketplace(api, option.value)
42
+ },
43
+ })
44
+ }
45
+
46
+ function openMarketplace(api, name) {
47
+ const entry = readRegistry().marketplaces?.[name]
48
+ if (!entry) {
49
+ toast(api, "error", `marketplace "${name}" not found (ocm list)`)
50
+ openMarketplaces(api)
51
+ return
52
+ }
53
+ const trusted = entry.trust?.code === "granted"
54
+ select(api, {
55
+ title: name,
56
+ options: [
57
+ { title: "Update", value: "update", description: "Pull latest changes and refresh links" },
58
+ { title: "Remove", value: "remove", description: "Remove this marketplace and its links" },
59
+ {
60
+ title: trusted ? "Untrust" : "Trust",
61
+ value: "trust",
62
+ description: trusted ? "Remove its executable components" : "Approve its executable components",
63
+ },
64
+ { title: "Pin", value: "pin", description: "Follow a specific branch or tag" },
65
+ { title: "Back", value: "back", description: "Back to the marketplace list" },
66
+ ],
67
+ onSelect: (option) => {
68
+ if (option.value === "update") updateFlow(api, name, () => openMarketplace(api, name))
69
+ else if (option.value === "remove") removeFlow(api, name)
70
+ else if (option.value === "trust") {
71
+ const back = () => openMarketplace(api, name)
72
+ if (trusted) untrustFlow(api, name, back)
73
+ else trustFlow(api, name, back)
74
+ } else if (option.value === "pin") pinFlow(api, name)
75
+ else openMarketplaces(api)
76
+ },
77
+ })
78
+ }
79
+
80
+ export async function updateFlow(api, name, back) {
81
+ const entry = readRegistry().marketplaces?.[name]
82
+ if (!entry) {
83
+ toast(api, "error", `marketplace "${name}" not found (ocm list)`)
84
+ back()
85
+ return
86
+ }
87
+ busy(api, `Updating ${name}...`)
88
+ try {
89
+ let changed = false
90
+ if (entry.local === false) {
91
+ const pull = await pullRepo(entry.dir, typeof entry.ref === "string" ? entry.ref : null)
92
+ if (!pull.ok) throw new Error(pull.output)
93
+ changed = pull.changed
94
+ }
95
+ const root = componentRoot(entry)
96
+ const links = materialize(name, root, { enabled: enabledPlugins(entry, root) })
97
+ if (links.warnings.length) toast(api, "warning", links.warnings.join("\n"))
98
+ const mutated = changed || links.created > 0
99
+ toast(api, "success", `${name}: ${changed ? "updated to a new revision" : "already up to date"}${mutated ? ` — ${NOTICE}` : ""}`)
100
+ } catch (err) {
101
+ toast(api, "error", `${name}: update failed: ${message(err)}`)
102
+ }
103
+ back()
104
+ }
105
+
106
+ async function removeFlow(api, name) {
107
+ const entry = readRegistry().marketplaces?.[name]
108
+ if (!entry) {
109
+ toast(api, "error", `marketplace "${name}" not found (ocm list)`)
110
+ openMarketplaces(api)
111
+ return
112
+ }
113
+ const lines = [`remove marketplace "${name}"?`]
114
+ for (const [plugin, record] of Object.entries(entry.plugins ?? {})) {
115
+ if (!record.collision) lines.push(` ${plugin} (${componentSummary(record)})`)
116
+ }
117
+ if (entry.local === false) lines.push(`the clone at ${entry.dir} is deleted`)
118
+ if (!(await confirm(api, name, lines.join("\n")))) {
119
+ openMarketplace(api, name)
120
+ return
121
+ }
122
+ try {
123
+ const result = removeMarketplace(name)
124
+ if (result.warnings.length) toast(api, "warning", result.warnings.join("\n"))
125
+ toast(api, "success", `removed marketplace "${name}" — ${NOTICE}`)
126
+ } catch (err) {
127
+ toast(api, "error", message(err))
128
+ }
129
+ openMarketplaces(api)
130
+ }
131
+
132
+ async function pinFlow(api, name) {
133
+ const ref = await prompt(api, `Pin ${name}`, "Branch or tag to follow (empty to follow the default branch)")
134
+ if (ref === null) {
135
+ openMarketplace(api, name)
136
+ return
137
+ }
138
+ try {
139
+ const result = await pinMarketplace(name, ref || null)
140
+ toast(api, "success", `marketplace "${name}" ${result.cleared ? "unpinned" : `pinned to ${result.ref}`} — ${NOTICE}`)
141
+ } catch (err) {
142
+ toast(api, "error", message(err))
143
+ }
144
+ openMarketplace(api, name)
145
+ }
146
+
147
+ export async function addMarketplaceFlow(api, back) {
148
+ const source = await prompt(api, "Add marketplace", "URL or path of the marketplace to add")
149
+ if (!source) {
150
+ back()
151
+ return
152
+ }
153
+ busy(api, `Adding ${source}...`)
154
+ let result
155
+ try {
156
+ result = await addMarketplace(source)
157
+ } catch (err) {
158
+ // a refused add still owes the user the diagnostics behind the refusal
159
+ if (Array.isArray(err.warnings) && err.warnings.length) toast(api, "warning", err.warnings.join("\n"))
160
+ alert(api, "ocm", `error: ${message(err)}`, back)
161
+ return
162
+ }
163
+ if (result.warnings.length) toast(api, "warning", result.warnings.join("\n"))
164
+ if (result.trustComponents.length && (await confirm(api, result.name, trustMessage(result.name, result.dir, result.trustComponents)))) {
165
+ const second = grantTrust(result.name)
166
+ if (second.report?.warnings.length) toast(api, "warning", second.report.warnings.join("\n"))
167
+ }
168
+ toast(api, "success", `added marketplace "${result.name}" — ${NOTICE}`)
169
+ back()
170
+ }
171
+
172
+ export async function runUpdateAll(api) {
173
+ busy(api, "Updating all marketplaces...")
174
+ try {
175
+ const result = await syncAll({ force: true })
176
+ const lines = []
177
+ if (result.updated.length) lines.push(`updated: ${result.updated.join(", ")}`)
178
+ if (result.unchanged.length) lines.push(`unchanged: ${result.unchanged.join(", ")}`)
179
+ if (result.failed.length) lines.push(`failed: ${result.failed.join(", ")}`)
180
+ for (const [name, error] of Object.entries(result.errors)) lines.push(` ${name}: ${error}`)
181
+ if (result.warnings?.length) lines.push(...result.warnings)
182
+ if (result.changed) lines.push(NOTICE)
183
+ alert(api, "ocm", lines.join("\n") || "no marketplaces added yet (ocm add <url|path>)", () => openMainMenu(api))
184
+ } catch (err) {
185
+ alert(api, "ocm", `error: ${message(err)}`, () => openMainMenu(api))
186
+ }
187
+ }
@@ -0,0 +1,101 @@
1
+ // The per-plugin flows of the /ocm TUI dialog (spec 10b): the plugin menu,
2
+ // install/uninstall, and the details view.
3
+ import { readRegistry, setEnabled } from "./core.js"
4
+ import { NOTICE, alert, componentSummary, confirm, message, select, toast } from "./ui-dialog.js"
5
+ import { openBrowse } from "./ui.js"
6
+ import { updateFlow } from "./ui-marketplaces.js"
7
+ import { trustFlow } from "./ui-trust.js"
8
+
9
+ // executable components ship blocked until the marketplace is trusted (spec 07)
10
+ export function blocked(record, entry) {
11
+ const components = record.components ?? {}
12
+ if (!(components.plugin?.length || components.mcp?.length)) return false
13
+ return entry.trust?.code !== "granted" || entry.trustPending === true
14
+ }
15
+
16
+ export function openPlugin(api, marketplace, name) {
17
+ const entry = readRegistry().marketplaces?.[marketplace]
18
+ const record = entry?.plugins?.[name]
19
+ if (!entry || !record) {
20
+ openBrowse(api)
21
+ return
22
+ }
23
+ const arg = `${name}@${marketplace}`
24
+ const options = [
25
+ { title: record.enabled ? "Uninstall" : "Install", value: "toggle", description: componentSummary(record) },
26
+ { title: "Details", value: "details", description: "The ocm info record" },
27
+ ]
28
+ if (blocked(record, entry)) {
29
+ options.push({ title: "Trust marketplace", value: "trust", description: `Approve executable components from ${marketplace}` })
30
+ }
31
+ options.push(
32
+ { title: `Update ${marketplace}`, value: "update", description: "Pull just this marketplace" },
33
+ { title: "Back", value: "back", description: "Back to the plugin list" },
34
+ )
35
+ select(api, {
36
+ title: arg,
37
+ options,
38
+ onSelect: (option) => {
39
+ if (option.value === "toggle") togglePlugin(api, marketplace, name)
40
+ else if (option.value === "details") showDetails(api, marketplace, name)
41
+ else if (option.value === "trust") trustFlow(api, marketplace, () => openPlugin(api, marketplace, name))
42
+ else if (option.value === "update") updateFlow(api, marketplace, () => openPlugin(api, marketplace, name))
43
+ else openBrowse(api)
44
+ },
45
+ })
46
+ }
47
+
48
+ async function togglePlugin(api, marketplace, name) {
49
+ const entry = readRegistry().marketplaces?.[marketplace]
50
+ const record = entry?.plugins?.[name]
51
+ if (!entry || !record) {
52
+ openBrowse(api)
53
+ return
54
+ }
55
+ const arg = `${name}@${marketplace}`
56
+ const enabling = !record.enabled
57
+ const executable = (record.components?.plugin?.length ?? 0) + (record.components?.mcp?.length ?? 0)
58
+ let text = enabling ? `Install ${arg} (${componentSummary(record)})?` : `Uninstall ${arg}?`
59
+ if (!enabling && executable) text += `\n\nit removes ${executable} executable component(s) opencode runs on start`
60
+ if (!(await confirm(api, arg, text))) {
61
+ openPlugin(api, marketplace, name)
62
+ return
63
+ }
64
+ try {
65
+ const result = setEnabled(arg, enabling)
66
+ if (result.disagreement) toast(api, "warning", result.disagreement)
67
+ if (result.report.warnings.length) toast(api, "warning", result.report.warnings.join("\n"))
68
+ toast(api, "success", `${enabling ? "installed" : "uninstalled"} ${arg} — ${NOTICE}`)
69
+ } catch (err) {
70
+ toast(api, "error", message(err))
71
+ }
72
+ openBrowse(api)
73
+ }
74
+
75
+ function showDetails(api, marketplace, name) {
76
+ const entry = readRegistry().marketplaces?.[marketplace]
77
+ const record = entry?.plugins?.[name]
78
+ if (!entry || !record) {
79
+ openBrowse(api)
80
+ return
81
+ }
82
+ const manifest = record.manifest ?? {}
83
+ const lines = [`${name} @ ${marketplace}`]
84
+ if (manifest.description) lines.push(`description: ${manifest.description}`)
85
+ if (record.version) lines.push(`version: ${record.version}`)
86
+ if (manifest.category) lines.push(`category: ${manifest.category}`)
87
+ lines.push(`enabled: ${record.enabled ? "yes" : "no"}`)
88
+ lines.push(`installed: ${record.installedAt ?? "no"}`)
89
+ lines.push(`marketplace: ${entry.url}${entry.ref ? ` @ ${entry.ref}` : ""}`)
90
+ lines.push(`trust: ${entry.trust?.code ?? "none"}`)
91
+ const components = record.components ?? {}
92
+ const named = [
93
+ ...(components.command ?? []).map((file) => `command ${name}:${file}`),
94
+ ...(components.agent ?? []).map((file) => `agent ${name}:${file}`),
95
+ ...(components.skill ?? []).map((rel) => `skill ${name}:${rel}`),
96
+ ...(components.plugin ?? []).map((file) => `plugin ocm--${name}--${file}`),
97
+ ...(components.mcp ?? []).map((server) => `mcp ocm--${name}--${server}`),
98
+ ]
99
+ if (named.length) lines.push("components:", ...named.map((line) => ` ${line}`))
100
+ alert(api, `${name}@${marketplace}`, lines.join("\n"), () => openPlugin(api, marketplace, name))
101
+ }
@@ -0,0 +1,68 @@
1
+ // The trust flows of the /ocm TUI dialog (spec 10b): the prompt renders the
2
+ // same component list the CLI prints before a trust decision (spec 07).
3
+ import { componentRoot, denyTrust, executableComponents, grantTrust, readRegistry } from "./core.js"
4
+ import { NOTICE, confirm, message, toast } from "./ui-dialog.js"
5
+
6
+ // the same block the CLI prints before a trust prompt (spec 07)
7
+ function trustMessage(name, dir, components) {
8
+ const lines = [`marketplace "${name}" ships code that opencode will execute:`]
9
+ for (const component of components) {
10
+ if (component.kind === "plugin") {
11
+ lines.push(` plugin ${component.plugin}/${component.name.replace(/\.[jt]s$/, "")} (${component.rel})`)
12
+ } else {
13
+ const value = component.value
14
+ const command = Array.isArray(value?.command) ? value.command.join(" ") : ""
15
+ const detail = value && typeof value.url === "string" ? `remote server: ${value.url}` : `local server: ${command}`
16
+ lines.push(` mcp ${component.plugin}/${component.name} (${detail})`)
17
+ }
18
+ }
19
+ lines.push("this code runs with your shell's permissions on every opencode start.")
20
+ lines.push(`review it at ${dir}`)
21
+ lines.push("trust this marketplace to run code?")
22
+ return lines.join("\n")
23
+ }
24
+
25
+ export async function trustFlow(api, name, back) {
26
+ const entry = readRegistry().marketplaces?.[name]
27
+ if (!entry) {
28
+ toast(api, "error", `marketplace "${name}" not found (ocm list)`)
29
+ back()
30
+ return
31
+ }
32
+ const components = executableComponents(componentRoot(entry), entry)
33
+ if (!components.length) {
34
+ toast(api, "info", `marketplace "${name}" ships no code; nothing to trust`)
35
+ back()
36
+ return
37
+ }
38
+ if (!(await confirm(api, name, trustMessage(name, entry.dir, components)))) {
39
+ back()
40
+ return
41
+ }
42
+ try {
43
+ const result = grantTrust(name)
44
+ if (result.report?.warnings.length) toast(api, "warning", result.report.warnings.join("\n"))
45
+ toast(api, "success", `marketplace "${name}" trusted to run code — ${NOTICE}`)
46
+ } catch (err) {
47
+ toast(api, "error", message(err))
48
+ }
49
+ back()
50
+ }
51
+
52
+ export async function untrustFlow(api, name, back) {
53
+ const text = `stop trusting marketplace "${name}"?\n\nits executable components are removed from opencode`
54
+ if (!(await confirm(api, name, text))) {
55
+ back()
56
+ return
57
+ }
58
+ try {
59
+ const result = denyTrust(name)
60
+ if (result.report.warnings.length) toast(api, "warning", result.report.warnings.join("\n"))
61
+ toast(api, "success", `marketplace "${name}" no longer trusted — ${NOTICE}`)
62
+ } catch (err) {
63
+ toast(api, "error", message(err))
64
+ }
65
+ back()
66
+ }
67
+
68
+ export { trustMessage }
package/loader/ui.js ADDED
@@ -0,0 +1,109 @@
1
+ // The /ocm TUI dialog entry (spec 10b): registers the /ocm command and
2
+ // renders the menu screens. Every mutation is a core function the CLI also
3
+ // calls; the flows live in the ui-*.js siblings.
4
+ import { readRegistry, searchPlugins } from "./core.js"
5
+ import { alert, componentSummary, prompt, select } from "./ui-dialog.js"
6
+ import { blocked, openPlugin } from "./ui-plugins.js"
7
+ import { addMarketplaceFlow, openMarketplaces, runUpdateAll } from "./ui-marketplaces.js"
8
+
9
+ export function openMainMenu(api) {
10
+ const marketplaces = Object.entries(readRegistry().marketplaces ?? {})
11
+ if (!marketplaces.length) {
12
+ alert(
13
+ api,
14
+ "ocm",
15
+ "No marketplaces added yet.\n\nAdd one from your terminal:\n ocm add <url|path>\n\nOr add one here.",
16
+ () => addMarketplaceFlow(api, () => openMainMenu(api)),
17
+ )
18
+ return
19
+ }
20
+ const plugins = marketplaces.reduce((count, [, entry]) => count + Object.keys(entry.plugins ?? {}).length, 0)
21
+ select(api, {
22
+ title: "ocm",
23
+ options: [
24
+ { title: "Browse plugins", value: "browse", description: `${plugins} plugins across ${marketplaces.length} marketplaces` },
25
+ { title: "Search", value: "search", description: "Find a plugin by name, tag or command" },
26
+ { title: "Marketplaces", value: "marketplaces", description: "Add, update, remove" },
27
+ { title: "Update all", value: "update-all", description: "Pull every marketplace now" },
28
+ ],
29
+ onSelect: (option) => {
30
+ if (option.value === "browse") openBrowse(api)
31
+ else if (option.value === "search") searchFlow(api)
32
+ else if (option.value === "marketplaces") openMarketplaces(api)
33
+ else runUpdateAll(api)
34
+ },
35
+ })
36
+ }
37
+
38
+ function pluginOption(registry, marketplace, name, value) {
39
+ const entry = registry.marketplaces[marketplace]
40
+ const record = entry.plugins[name]
41
+ const suffix = !record.enabled ? " (disabled)" : blocked(record, entry) ? " (blocked)" : ""
42
+ return {
43
+ title: `${name}@${marketplace}${suffix}`,
44
+ value,
45
+ description: record.manifest?.description || componentSummary(record),
46
+ category: marketplace,
47
+ }
48
+ }
49
+
50
+ function openPluginList(api, title, items) {
51
+ const registry = readRegistry()
52
+ select(api, {
53
+ title,
54
+ options: items.map((item, i) => pluginOption(registry, item.marketplace, item.name, String(i))),
55
+ onSelect: (option) => {
56
+ const item = items[Number(option.value)]
57
+ if (item) openPlugin(api, item.marketplace, item.name)
58
+ },
59
+ })
60
+ }
61
+
62
+ export function openBrowse(api) {
63
+ const items = []
64
+ for (const [marketplace, entry] of Object.entries(readRegistry().marketplaces ?? {})) {
65
+ for (const name of Object.keys(entry.plugins ?? {})) items.push({ marketplace, name })
66
+ }
67
+ if (!items.length) {
68
+ alert(api, "ocm", "No plugins found in any marketplace.", () => openMainMenu(api))
69
+ return
70
+ }
71
+ openPluginList(api, "Browse plugins", items)
72
+ }
73
+
74
+ async function searchFlow(api) {
75
+ const query = await prompt(api, "Search plugins", "Find a plugin by name, tag or command")
76
+ if (!query) {
77
+ openMainMenu(api)
78
+ return
79
+ }
80
+ const matches = searchPlugins(query)
81
+ if (!matches.length) {
82
+ alert(api, "ocm", `No plugins match "${query}".`, () => openMainMenu(api))
83
+ return
84
+ }
85
+ openPluginList(api, `Search: ${query}`, matches.map((m) => ({ marketplace: m.marketplace, name: m.plugin })))
86
+ }
87
+
88
+ export default {
89
+ id: "ocm-ui",
90
+ tui: async (api) => {
91
+ const unregister = api.keymap?.registerLayer?.({
92
+ commands: [
93
+ {
94
+ namespace: "palette",
95
+ name: "ocm.open",
96
+ title: "ocm marketplace",
97
+ category: "ocm",
98
+ slashName: "ocm",
99
+ slash: { name: "ocm" },
100
+ run: () => openMainMenu(api),
101
+ },
102
+ ],
103
+ bindings: [],
104
+ })
105
+ if (typeof unregister === "function" && typeof api.lifecycle?.onDispose === "function") {
106
+ api.lifecycle.onDispose(unregister)
107
+ }
108
+ },
109
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@wntic/ocm",
3
+ "version": "0.1.0",
4
+ "description": "File-based plugin marketplace for opencode: skills, agents, commands distributed via git repos",
5
+ "type": "module",
6
+ "bin": {
7
+ "ocm": "./bin/ocm.ts"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "loader",
13
+ "template",
14
+ "README.md"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "dev": "bun run ./bin/ocm.ts",
21
+ "test": "bun test"
22
+ },
23
+ "license": "MIT",
24
+ "engines": {
25
+ "bun": ">=1.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "^1.4.0",
29
+ "tsx": "^4.23.13",
30
+ "typescript": "^7.0.2"
31
+ }
32
+ }
@@ -0,0 +1,79 @@
1
+ // spec 12 doctor: the opencode.json checks. Every fix goes through the core's
2
+ // atomic, ownership-scoped writers (setSkillsPath, removeMcpKeys), which
3
+ // return a warning string on failure instead of throwing — a failed write is
4
+ // an error finding, never a corrupted config.
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { removeMcpKeys, setSkillsPath } from "../../loader/core.js"
7
+ import type { CoreRegistry } from "../../loader/core.js"
8
+ import { OCM_LINKS_DIR, OPENCODE_GLOBAL_CONFIG } from "../paths"
9
+ import { error, fixed, warning, type Finding } from "../findings"
10
+
11
+ // undefined: no config file (normal); null: present but unusable
12
+ function readConfig(): Record<string, unknown> | null | undefined {
13
+ let raw: string | undefined
14
+ try {
15
+ raw = readFileSync(OPENCODE_GLOBAL_CONFIG, "utf8")
16
+ } catch {
17
+ return undefined
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(raw) as unknown
21
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
22
+ return parsed as Record<string, unknown>
23
+ }
24
+ } catch {}
25
+ return null
26
+ }
27
+
28
+ export function checkConfig(findings: Finding[], registry: CoreRegistry, fix: boolean, registryUsable: boolean): void {
29
+ const config = readConfig()
30
+ if (config === null) {
31
+ findings.push(warning(`${OPENCODE_GLOBAL_CONFIG}: not valid JSON, left untouched — fix it by hand`))
32
+ return
33
+ }
34
+ if (config === undefined) return
35
+ checkSkillsPaths(config, findings, fix)
36
+ checkMcpKeys(config, registry, findings, fix && registryUsable)
37
+ }
38
+
39
+ // only entries under ocm's links dir are ocm's to remove; a user's orphaned
40
+ // entry stays (ownership)
41
+ function checkSkillsPaths(config: Record<string, unknown>, findings: Finding[], fix: boolean): void {
42
+ const skills = config.skills
43
+ if (typeof skills !== "object" || skills === null || !Array.isArray((skills as { paths?: unknown }).paths)) return
44
+ for (const entry of (skills as { paths: unknown[] }).paths) {
45
+ if (typeof entry !== "string" || !entry.startsWith(`${OCM_LINKS_DIR}/`) || existsSync(entry)) continue
46
+ if (!fix) {
47
+ findings.push(error(`${entry}: orphaned ocm skills path — target is gone (ocm doctor --fix)`))
48
+ continue
49
+ }
50
+ const failure = setSkillsPath(entry, false)
51
+ if (failure) findings.push(error(failure))
52
+ else findings.push(fixed(`${entry}: removed from skills.paths`))
53
+ }
54
+ }
55
+
56
+ function checkMcpKeys(config: Record<string, unknown>, registry: CoreRegistry, findings: Finding[], fix: boolean): void {
57
+ const mcp = config.mcp
58
+ if (typeof mcp !== "object" || mcp === null || Array.isArray(mcp)) return
59
+ const known = new Set<string>()
60
+ for (const entry of Object.values(registry.marketplaces)) {
61
+ for (const name of Object.keys(entry.plugins ?? {})) known.add(name)
62
+ }
63
+ const orphaned: { key: string; plugin: string }[] = []
64
+ for (const key of Object.keys(mcp)) {
65
+ if (!key.startsWith("ocm--")) continue
66
+ const plugin = key.slice(5).split("--")[0]!
67
+ if (!known.has(plugin)) orphaned.push({ key, plugin })
68
+ }
69
+ if (!orphaned.length) return
70
+ if (!fix) {
71
+ for (const { key, plugin } of orphaned) {
72
+ findings.push(error(`${key}: orphaned MCP key — plugin "${plugin}" is not in the registry`))
73
+ }
74
+ return
75
+ }
76
+ const failure = removeMcpKeys([...new Set(orphaned.map(({ plugin }) => plugin))])
77
+ if (failure) findings.push(error(failure))
78
+ else for (const { key } of orphaned) findings.push(fixed(`${key}: removed from opencode.json`))
79
+ }