@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,145 @@
|
|
|
1
|
+
// Component-file linting for `ocm validate`: command/agent markdown, skill
|
|
2
|
+
// SKILL.md files and plugin js modules. Frontmatter parsing is deliberately
|
|
3
|
+
// tolerant-but-strict — no sanitizer fallback, so YAML that only survives by
|
|
4
|
+
// luck is caught here instead of in the field.
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
6
|
+
import { join, relative } from "node:path"
|
|
7
|
+
import type { CoreDiscoveredPlugin } from "../../loader/core.js"
|
|
8
|
+
import { error, warning, type Finding } from "../findings"
|
|
9
|
+
|
|
10
|
+
// `commands` and `command` (likewise the other types) are both opencode-valid
|
|
11
|
+
// source directories, so both are searched
|
|
12
|
+
function locate(pluginDir: string, dirs: string[], name: string): string | null {
|
|
13
|
+
for (const dir of dirs) {
|
|
14
|
+
const path = join(pluginDir, dir, name)
|
|
15
|
+
if (existsSync(path)) return path
|
|
16
|
+
}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function frontmatter(content: string): { fm: string; body: string } | null {
|
|
21
|
+
if (!content.startsWith("---\n")) return null
|
|
22
|
+
const close = content.indexOf("\n---\n", 3)
|
|
23
|
+
if (close === -1) return null
|
|
24
|
+
return { fm: content.slice(4, close), body: content.slice(close + 5) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// strict YAML rejects a plain scalar containing ": "; opencode's sanitizer
|
|
28
|
+
// rescues it, so a quoted value passes and an unquoted one is an error
|
|
29
|
+
function lintStrictYaml(rel: string, fm: string, findings: Finding[]): void {
|
|
30
|
+
for (const line of fm.split("\n")) {
|
|
31
|
+
const match = line.match(/^([^\s:]+):\s*(.*)$/)
|
|
32
|
+
if (!match) continue
|
|
33
|
+
const value = match[2]!
|
|
34
|
+
if (value.includes(": ") && !/^["'[]/.test(value)) {
|
|
35
|
+
findings.push(error(`${rel}: unquoted ": " in "${match[1]}" — strict YAML would reject this; quote the value`))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function lintMarkdown(
|
|
41
|
+
root: string,
|
|
42
|
+
plugin: CoreDiscoveredPlugin,
|
|
43
|
+
dirs: string[],
|
|
44
|
+
file: string,
|
|
45
|
+
kind: "command" | "agent",
|
|
46
|
+
findings: Finding[],
|
|
47
|
+
): void {
|
|
48
|
+
const path = locate(plugin.dir, dirs, file)
|
|
49
|
+
if (!path) return
|
|
50
|
+
const rel = relative(root, path)
|
|
51
|
+
let content: string
|
|
52
|
+
try {
|
|
53
|
+
content = readFileSync(path, "utf8")
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const parsed = frontmatter(content)
|
|
58
|
+
if (parsed === null) {
|
|
59
|
+
findings.push(warning(`${rel}: no frontmatter — opencode requires a description`))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
lintStrictYaml(rel, parsed.fm, findings)
|
|
63
|
+
if (!parsed.body.trim()) {
|
|
64
|
+
findings.push(error(`${rel}: empty body — opencode requires the template`))
|
|
65
|
+
}
|
|
66
|
+
if (kind === "command" && /![a-zA-Z]/.test(parsed.body)) {
|
|
67
|
+
findings.push(warning(`${rel}: "!" shell substitution — ocm info surfaces this plugin as shell-executing`))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lintSkillFields(
|
|
72
|
+
rel: string,
|
|
73
|
+
plugin: CoreDiscoveredPlugin,
|
|
74
|
+
fm: string,
|
|
75
|
+
skills: Map<string, string>,
|
|
76
|
+
findings: Finding[],
|
|
77
|
+
): void {
|
|
78
|
+
const values = new Map<string, string>()
|
|
79
|
+
for (const line of fm.split("\n")) {
|
|
80
|
+
const match = line.match(/^([^\s:]+):\s*(.*)$/)
|
|
81
|
+
if (match) values.set(match[1]!, match[2]!)
|
|
82
|
+
}
|
|
83
|
+
const strip = (value: string | undefined) => value?.trim().replace(/^["']|["']$/g, "")
|
|
84
|
+
const name = strip(values.get("name"))
|
|
85
|
+
const description = strip(values.get("description"))
|
|
86
|
+
if (!name) {
|
|
87
|
+
findings.push(error(`${rel}: frontmatter has no "name"`))
|
|
88
|
+
} else {
|
|
89
|
+
const namespaced = `${plugin.name}:${name}`
|
|
90
|
+
const previous = skills.get(namespaced)
|
|
91
|
+
if (previous) findings.push(error(`${previous} and ${rel}: both produce "${namespaced}"`))
|
|
92
|
+
else skills.set(namespaced, rel)
|
|
93
|
+
}
|
|
94
|
+
if (!description) {
|
|
95
|
+
findings.push(error(`${rel}: frontmatter has no "description"`))
|
|
96
|
+
} else if (description.length > 1024) {
|
|
97
|
+
findings.push(error(`${rel}: "description" must be 1-1024 characters`))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function lintSkill(
|
|
102
|
+
root: string,
|
|
103
|
+
plugin: CoreDiscoveredPlugin,
|
|
104
|
+
relDir: string,
|
|
105
|
+
skills: Map<string, string>,
|
|
106
|
+
findings: Finding[],
|
|
107
|
+
): void {
|
|
108
|
+
const dir = locate(plugin.dir, ["skills", "skill"], relDir)
|
|
109
|
+
if (!dir) return
|
|
110
|
+
const rel = relative(root, join(dir, "SKILL.md"))
|
|
111
|
+
let content: string
|
|
112
|
+
try {
|
|
113
|
+
content = readFileSync(join(dir, "SKILL.md"), "utf8")
|
|
114
|
+
} catch {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const parsed = frontmatter(content)
|
|
118
|
+
if (parsed === null) {
|
|
119
|
+
findings.push(error(`${rel}: no frontmatter — a skill without it is invisible`))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
lintStrictYaml(rel, parsed.fm, findings)
|
|
123
|
+
lintSkillFields(rel, plugin, parsed.fm, skills, findings)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// static checks only: the failure mode in the field is a red line on every
|
|
127
|
+
// opencode start, so a suspicious export shape is an error, not a warning
|
|
128
|
+
export function lintPluginJs(root: string, plugin: CoreDiscoveredPlugin, file: string, findings: Finding[]): void {
|
|
129
|
+
const path = locate(plugin.dir, ["plugin", "plugins"], file)
|
|
130
|
+
if (!path) return
|
|
131
|
+
const rel = relative(root, path)
|
|
132
|
+
let content: string
|
|
133
|
+
try {
|
|
134
|
+
content = readFileSync(path, "utf8")
|
|
135
|
+
} catch {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (/\btui\s*:/.test(content)) {
|
|
139
|
+
findings.push(error(`${rel}: exports { id, tui } — tui plugins are not supported; ship { id, server }`))
|
|
140
|
+
} else if (/\bsetup\s*:/.test(content)) {
|
|
141
|
+
findings.push(error(`${rel}: exports { id, setup } — opencode silently rejects it; ship { id, server }`))
|
|
142
|
+
} else if (!/\bserver\s*:/.test(content) && !/export\s+default\s+(async\s+)?[(f]/.test(content)) {
|
|
143
|
+
findings.push(error(`${rel}: does not default-export { id, server } or a function`))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// spec 12 `ocm validate`: lint a marketplace repo for its author. Structure
|
|
2
|
+
// checks live here; component-file checks (markdown, skills, plugin js) are in
|
|
3
|
+
// validate-files.ts.
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs"
|
|
5
|
+
import { join, relative } from "node:path"
|
|
6
|
+
import { dirClashes, discoverPlugins, lintCrossTool } from "../../loader/core.js"
|
|
7
|
+
import type { CoreDiscoveredPlugin } from "../../loader/core.js"
|
|
8
|
+
import { error, reportFindings, warning, type Finding } from "../findings"
|
|
9
|
+
import { NAME_RE, lintMarketplaceJson, lintMcpJson, lintPluginJson, type MarketplaceManifest } from "../manifest-lint"
|
|
10
|
+
import { lintMarkdown, lintPluginJs, lintSkill } from "./validate-files"
|
|
11
|
+
|
|
12
|
+
const TYPO_FILES = new Set(["plugin.ts", "SKILLS.md", "Skill.md"])
|
|
13
|
+
|
|
14
|
+
export function validate(path?: string): void {
|
|
15
|
+
const root = path ?? process.cwd()
|
|
16
|
+
if (!existsSync(root)) throw new Error(`marketplace directory "${root}" does not exist`)
|
|
17
|
+
console.log(`validate ${root}`)
|
|
18
|
+
const findings: Finding[] = []
|
|
19
|
+
const manifest = lintMarketplaceJson(root, findings)
|
|
20
|
+
const skills = new Map<string, string>()
|
|
21
|
+
for (const plugin of discoverPlugins(root)) {
|
|
22
|
+
lintPlugin(root, plugin, manifest, skills, findings)
|
|
23
|
+
}
|
|
24
|
+
for (const message of lintCrossTool(root)) findings.push(warning(message))
|
|
25
|
+
if (reportFindings(findings)) process.exitCode = 1
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lintPlugin(
|
|
29
|
+
root: string,
|
|
30
|
+
plugin: CoreDiscoveredPlugin,
|
|
31
|
+
manifest: MarketplaceManifest,
|
|
32
|
+
skills: Map<string, string>,
|
|
33
|
+
findings: Finding[],
|
|
34
|
+
): void {
|
|
35
|
+
const rel = relative(root, plugin.dir) || "."
|
|
36
|
+
if (!NAME_RE.test(plugin.name)) findings.push(error(`${rel}: directory name must match ${NAME_RE}`))
|
|
37
|
+
if (plugin.name.length > 64) findings.push(error(`${rel}: directory name is longer than 64 characters`))
|
|
38
|
+
for (const clash of dirClashes(plugin.dir)) {
|
|
39
|
+
findings.push(error(`${rel}: ${clash} — both produce the same materialized name`))
|
|
40
|
+
}
|
|
41
|
+
lintTypos(rel, plugin.dir, findings)
|
|
42
|
+
const record = lintPluginJson(root, plugin.dir, plugin.name, findings)
|
|
43
|
+
lintMcpJson(root, plugin.dir, findings)
|
|
44
|
+
const entry = manifest.entries.get(plugin.name)
|
|
45
|
+
const fromMarketplace = typeof entry?.version === "string" ? entry.version : undefined
|
|
46
|
+
const fromPlugin = typeof record?.version === "string" ? record.version : undefined
|
|
47
|
+
if (fromMarketplace && fromPlugin && fromMarketplace !== fromPlugin) {
|
|
48
|
+
findings.push(warning(`${rel}: version disagrees — marketplace.json ${fromMarketplace}, plugin.json ${fromPlugin}`))
|
|
49
|
+
}
|
|
50
|
+
for (const file of plugin.components.command ?? []) {
|
|
51
|
+
lintMarkdown(root, plugin, ["commands", "command"], file, "command", findings)
|
|
52
|
+
}
|
|
53
|
+
for (const file of plugin.components.agent ?? []) {
|
|
54
|
+
lintMarkdown(root, plugin, ["agents", "agent"], file, "agent", findings)
|
|
55
|
+
}
|
|
56
|
+
for (const dir of plugin.components.skill ?? []) {
|
|
57
|
+
lintSkill(root, plugin, dir, skills, findings)
|
|
58
|
+
}
|
|
59
|
+
for (const file of plugin.components.plugin ?? []) {
|
|
60
|
+
lintPluginJs(root, plugin, file, findings)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function lintTypos(rel: string, pluginDir: string, findings: Finding[]): void {
|
|
65
|
+
if (existsSync(join(pluginDir, "skills")) && existsSync(join(pluginDir, "skill"))) {
|
|
66
|
+
findings.push(warning(`${rel}: both "skill" and "skills" exist — one is probably a typo`))
|
|
67
|
+
}
|
|
68
|
+
let names: string[]
|
|
69
|
+
try {
|
|
70
|
+
names = readdirSync(pluginDir)
|
|
71
|
+
} catch {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
if (TYPO_FILES.has(name)) findings.push(warning(`${rel}/${name}: likely a typo — not discovered as a component`))
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { DiscoveredPlugin } from "./types"
|
|
4
|
+
|
|
5
|
+
export { discoverMarketplace, discoveryError, nameDisagreement, readManifest } from "../loader/core.js"
|
|
6
|
+
|
|
7
|
+
export type { DiscoveredPlugin }
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readJsonRecord(file: string): Record<string, unknown> | undefined {
|
|
14
|
+
if (!existsSync(file)) return undefined
|
|
15
|
+
try {
|
|
16
|
+
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"))
|
|
17
|
+
if (isRecord(parsed)) return parsed
|
|
18
|
+
} catch {}
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectRenames(raw: Record<string, unknown> | undefined, into: Record<string, string | null>): void {
|
|
23
|
+
if (!raw || !isRecord(raw.renames)) return
|
|
24
|
+
for (const [from, to] of Object.entries(raw.renames)) {
|
|
25
|
+
if (typeof to === "string" || to === null) into[from] = to
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// spec 08: renames come from each discovered plugin's plugin.json, with the
|
|
30
|
+
// marketplace manifest winning on conflict
|
|
31
|
+
export function readRenames(marketplaceDir: string, plugins: DiscoveredPlugin[]): Record<string, string | null> {
|
|
32
|
+
const renames: Record<string, string | null> = {}
|
|
33
|
+
for (const plugin of plugins) collectRenames(readJsonRecord(join(plugin.dir, "plugin.json")), renames)
|
|
34
|
+
collectRenames(readJsonRecord(join(marketplaceDir, "marketplace.json")), renames)
|
|
35
|
+
return renames
|
|
36
|
+
}
|
package/src/findings.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The spec 12 finding format, shared by `ocm validate` and `ocm doctor`:
|
|
2
|
+
// two-space indent, severity padded to 8, then the message. The summary line
|
|
3
|
+
// appears only when something was found, and "fixed" never counts toward it —
|
|
4
|
+
// a fully repaired `doctor --fix` exits 0.
|
|
5
|
+
export interface Finding {
|
|
6
|
+
severity: "error" | "warning" | "fixed"
|
|
7
|
+
message: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function error(message: string): Finding {
|
|
11
|
+
return { severity: "error", message }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function warning(message: string): Finding {
|
|
15
|
+
return { severity: "warning", message }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fixed(message: string): Finding {
|
|
19
|
+
return { severity: "fixed", message }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// prints the findings and the summary; returns true when any error was found
|
|
23
|
+
export function reportFindings(findings: Finding[]): boolean {
|
|
24
|
+
for (const finding of findings) {
|
|
25
|
+
console.log(` ${finding.severity.padEnd(8)}${finding.message}`)
|
|
26
|
+
}
|
|
27
|
+
const errors = findings.filter((finding) => finding.severity === "error").length
|
|
28
|
+
const warnings = findings.filter((finding) => finding.severity === "warning").length
|
|
29
|
+
if (errors + warnings > 0) {
|
|
30
|
+
console.log(`${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`)
|
|
31
|
+
}
|
|
32
|
+
return errors > 0
|
|
33
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
export function git(args: string[], cwd?: string): { ok: boolean; stdout: string; stderr: string } {
|
|
4
|
+
const result = spawnSync("git", args, {
|
|
5
|
+
cwd,
|
|
6
|
+
encoding: "utf8",
|
|
7
|
+
timeout: 120_000,
|
|
8
|
+
})
|
|
9
|
+
return {
|
|
10
|
+
ok: result.status === 0,
|
|
11
|
+
stdout: (result.stdout ?? "").trim(),
|
|
12
|
+
stderr: (result.stderr ?? "").trim(),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clone(url: string, dir: string, ref?: string | null): void {
|
|
17
|
+
const args = ["clone", "--depth", "1"]
|
|
18
|
+
if (ref) args.push("--branch", ref)
|
|
19
|
+
args.push(url, dir)
|
|
20
|
+
const result = git(args)
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
throw new Error(`git clone failed: ${result.stderr || result.stdout}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { installLoader, migrateLegacyLayout, uninstallLoader } from "./loader"
|
|
2
|
+
import { migrateInstallation } from "./migrate"
|
|
3
|
+
import { add, pin, remove } from "./commands/marketplace"
|
|
4
|
+
import { update } from "./commands/update"
|
|
5
|
+
import { install, scan, setMode, uninstall } from "./commands/plugins"
|
|
6
|
+
import { trust, untrust } from "./commands/trust"
|
|
7
|
+
import { list } from "./commands/list"
|
|
8
|
+
import { search } from "./commands/search"
|
|
9
|
+
import { info } from "./commands/info"
|
|
10
|
+
import { validate } from "./commands/validate"
|
|
11
|
+
import { doctor } from "./commands/doctor"
|
|
12
|
+
|
|
13
|
+
const HELP = `ocm - file-based plugin marketplace for opencode
|
|
14
|
+
|
|
15
|
+
usage:
|
|
16
|
+
ocm init install auto-sync loader
|
|
17
|
+
ocm add <url|path> [--ref <ref>] [--explicit] [--name <name>] [--trust|--no-trust]
|
|
18
|
+
add a marketplace (github url or local dir)
|
|
19
|
+
ocm remove <name> remove a marketplace and its links
|
|
20
|
+
ocm update [name|plugin@mp] [--quiet] [--json] [--trust|--no-trust]
|
|
21
|
+
pull latest changes (all, one marketplace or one plugin's marketplace)
|
|
22
|
+
ocm pin <name> <ref> follow a branch or tag
|
|
23
|
+
ocm pin <name> --clear back to the default branch
|
|
24
|
+
ocm list [--all] [--json] list marketplaces and plugins
|
|
25
|
+
ocm search <query> [--enabled-only] [--json]
|
|
26
|
+
search cached plugin metadata
|
|
27
|
+
ocm info <plugin>[@<marketplace>] [--json]
|
|
28
|
+
show a plugin's cached record
|
|
29
|
+
ocm install <plugin>[@<mp>] [--force]
|
|
30
|
+
enable a plugin and materialize its components
|
|
31
|
+
ocm uninstall <plugin>[@<mp>] disable a plugin and remove its links
|
|
32
|
+
ocm enable <plugin>[@<mp>] alias of install
|
|
33
|
+
ocm disable <plugin>[@<mp>] alias of uninstall
|
|
34
|
+
ocm mode <name> <auto|explicit> change when new upstream plugins install
|
|
35
|
+
ocm trust <name> approve a marketplace's executable components
|
|
36
|
+
ocm untrust <name> revoke trust and remove executable components
|
|
37
|
+
ocm scan <url|path|plugin> dry-run: show what would be installed
|
|
38
|
+
ocm validate [path] lint a marketplace repo (default: current directory)
|
|
39
|
+
ocm doctor [--fix] diagnose this installation; --fix applies safe fixes
|
|
40
|
+
ocm loader uninstall remove auto-sync loader
|
|
41
|
+
|
|
42
|
+
examples:
|
|
43
|
+
ocm add https://github.com/user/opencode-marketplace
|
|
44
|
+
ocm add ~/plugins/my-marketplace
|
|
45
|
+
ocm install commit-tools
|
|
46
|
+
ocm update`
|
|
47
|
+
|
|
48
|
+
// flags that take a value: `--name foo` or `--name=foo`
|
|
49
|
+
const VALUE_FLAGS = new Set(["name", "ref"])
|
|
50
|
+
|
|
51
|
+
interface ParsedArgs {
|
|
52
|
+
positional: string[]
|
|
53
|
+
flags: Set<string>
|
|
54
|
+
values: Record<string, string>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseArgs(args: string[]): ParsedArgs {
|
|
58
|
+
const positional: string[] = []
|
|
59
|
+
const flags = new Set<string>()
|
|
60
|
+
const values: Record<string, string> = {}
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const arg = args[i]!
|
|
63
|
+
if (!arg.startsWith("--")) {
|
|
64
|
+
positional.push(arg)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const eq = arg.indexOf("=")
|
|
68
|
+
if (eq !== -1) {
|
|
69
|
+
values[arg.slice(2, eq)] = arg.slice(eq + 1)
|
|
70
|
+
} else if (VALUE_FLAGS.has(arg.slice(2))) {
|
|
71
|
+
values[arg.slice(2)] = args[++i] ?? ""
|
|
72
|
+
} else {
|
|
73
|
+
flags.add(arg.slice(2))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { positional, flags, values }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `--trust` and `--no-trust` carry opposite decisions; absence means prompt
|
|
80
|
+
function trustFlag(flags: Set<string>): boolean | undefined {
|
|
81
|
+
if (flags.has("trust")) return true
|
|
82
|
+
if (flags.has("no-trust")) return false
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function main(argv: string[]): Promise<void> {
|
|
87
|
+
migrateLegacyLayout()
|
|
88
|
+
migrateInstallation()
|
|
89
|
+
const [command, ...rest] = argv
|
|
90
|
+
const { positional, flags, values } = parseArgs(rest)
|
|
91
|
+
|
|
92
|
+
switch (command) {
|
|
93
|
+
case undefined:
|
|
94
|
+
case "help":
|
|
95
|
+
case "--help":
|
|
96
|
+
case "-h":
|
|
97
|
+
console.log(HELP)
|
|
98
|
+
break
|
|
99
|
+
case "init":
|
|
100
|
+
installLoader()
|
|
101
|
+
break
|
|
102
|
+
case "add":
|
|
103
|
+
requireArg(positional[0], "missing marketplace url or path")
|
|
104
|
+
await add(positional[0]!, { explicit: flags.has("explicit"), name: values.name, ref: values.ref, trust: trustFlag(flags) })
|
|
105
|
+
break
|
|
106
|
+
case "remove":
|
|
107
|
+
requireArg(positional[0], "missing marketplace name")
|
|
108
|
+
remove(positional[0]!)
|
|
109
|
+
break
|
|
110
|
+
case "update":
|
|
111
|
+
await update(positional[0], { quiet: flags.has("quiet"), json: flags.has("json"), trust: trustFlag(flags) })
|
|
112
|
+
break
|
|
113
|
+
case "pin":
|
|
114
|
+
requireArg(positional[0], "missing marketplace name")
|
|
115
|
+
await pin(positional[0]!, positional[1], flags.has("clear"))
|
|
116
|
+
break
|
|
117
|
+
case "list":
|
|
118
|
+
list({ all: flags.has("all"), json: flags.has("json") })
|
|
119
|
+
break
|
|
120
|
+
case "search":
|
|
121
|
+
requireArg(positional[0], "missing search query")
|
|
122
|
+
search(positional[0]!, { enabledOnly: flags.has("enabled-only"), json: flags.has("json") })
|
|
123
|
+
break
|
|
124
|
+
case "info":
|
|
125
|
+
requireArg(positional[0], "missing plugin name")
|
|
126
|
+
info(positional[0]!, { json: flags.has("json") })
|
|
127
|
+
break
|
|
128
|
+
case "install":
|
|
129
|
+
case "enable":
|
|
130
|
+
requireArg(positional[0], "missing plugin name")
|
|
131
|
+
install(positional[0]!, flags.has("force"))
|
|
132
|
+
break
|
|
133
|
+
case "uninstall":
|
|
134
|
+
case "disable":
|
|
135
|
+
requireArg(positional[0], "missing plugin name")
|
|
136
|
+
uninstall(positional[0]!)
|
|
137
|
+
break
|
|
138
|
+
case "mode":
|
|
139
|
+
requireArg(positional[0], "missing marketplace name")
|
|
140
|
+
requireArg(positional[1], "missing mode (auto or explicit)")
|
|
141
|
+
setMode(positional[0]!, positional[1]!)
|
|
142
|
+
break
|
|
143
|
+
case "trust":
|
|
144
|
+
requireArg(positional[0], "missing marketplace name")
|
|
145
|
+
await trust(positional[0]!)
|
|
146
|
+
break
|
|
147
|
+
case "untrust":
|
|
148
|
+
requireArg(positional[0], "missing marketplace name")
|
|
149
|
+
await untrust(positional[0]!)
|
|
150
|
+
break
|
|
151
|
+
case "scan":
|
|
152
|
+
requireArg(positional[0], "missing url, path or plugin")
|
|
153
|
+
scan(positional[0]!)
|
|
154
|
+
break
|
|
155
|
+
case "validate":
|
|
156
|
+
validate(positional[0])
|
|
157
|
+
break
|
|
158
|
+
case "doctor":
|
|
159
|
+
doctor(flags.has("fix"))
|
|
160
|
+
break
|
|
161
|
+
case "loader":
|
|
162
|
+
if (positional[0] === "uninstall") {
|
|
163
|
+
uninstallLoader()
|
|
164
|
+
} else {
|
|
165
|
+
throw new Error('unknown loader command, expected "ocm loader uninstall"')
|
|
166
|
+
}
|
|
167
|
+
break
|
|
168
|
+
default:
|
|
169
|
+
throw new Error(`unknown command "${command}" (ocm help)`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function requireArg(arg: string | undefined, message: string): void {
|
|
174
|
+
if (!arg) throw new Error(message)
|
|
175
|
+
}
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { componentRoot, enabledPlugins, materialize as coreMaterialize } from "../loader/core.js"
|
|
2
|
+
import type { CoreMaterializeReport } from "../loader/core.js"
|
|
3
|
+
import type { MarketplaceEntry } from "./types"
|
|
4
|
+
|
|
5
|
+
export { componentRoot, incumbentMarketplace, registerPlugins, removeMcpKeys } from "../loader/core.js"
|
|
6
|
+
|
|
7
|
+
export function materializeLinks(
|
|
8
|
+
name: string,
|
|
9
|
+
entry: MarketplaceEntry,
|
|
10
|
+
force = false,
|
|
11
|
+
plugin?: string,
|
|
12
|
+
): CoreMaterializeReport {
|
|
13
|
+
const dir = componentRoot(entry)
|
|
14
|
+
return coreMaterialize(name, dir, { enabled: enabledPlugins(entry, dir), force, plugin })
|
|
15
|
+
}
|