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