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