@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,170 @@
1
+ // spec 12 doctor: the link-layer checks — stray ocm files, broken symlinks,
2
+ // drifted materialization and paths under directories ocm never owns. Every
3
+ // removal proves ownership first; an unowned path is reported, never touched.
4
+ import { existsSync, readFileSync, readdirSync, readlinkSync, rmSync } from "node:fs"
5
+ import { dirname, join } from "node:path"
6
+ import { componentRoot, discoverPlugins, enabledPlugins } from "../../loader/core.js"
7
+ import type { CoreRegistry } from "../../loader/core.js"
8
+ import { materializeLinks } from "../install"
9
+ import { HOME, OCM_LINKS_DIR, OPENCODE_AGENTS_DIR, OPENCODE_COMMANDS_DIR, OPENCODE_PLUGINS_DIR } from "../paths"
10
+ import { error, fixed, type Finding } from "../findings"
11
+
12
+ function errText(err: unknown): string {
13
+ return err instanceof Error ? err.message : String(err)
14
+ }
15
+
16
+ // raw-string prefix compare only: a raw symlink target must never be compared
17
+ // against a realpath'd directory (macOS puts temp dirs behind /var)
18
+ function insideDir(target: string, dir: string): boolean {
19
+ return target === dir || target.startsWith(dir + "/")
20
+ }
21
+
22
+ function managedRoots(registry: CoreRegistry): string[] {
23
+ return Object.values(registry.marketplaces).map((entry) => componentRoot(entry))
24
+ }
25
+
26
+ function removePath(path: string, findings: Finding[]): void {
27
+ try {
28
+ rmSync(path, { force: true, recursive: true })
29
+ findings.push(fixed(`${path}: removed`))
30
+ } catch (err) {
31
+ findings.push(error(`${path}: cannot remove — ${errText(err)}`))
32
+ }
33
+ }
34
+
35
+ // spec 01: no ocm file other than ocm-loader.js may live in plugins/
36
+ export function checkStrays(registry: CoreRegistry, findings: Finding[], fix: boolean): void {
37
+ const claimed = new Set<string>()
38
+ for (const entry of Object.values(registry.marketplaces)) {
39
+ for (const [name, plugin] of Object.entries(entry.plugins ?? {})) {
40
+ for (const file of plugin.components?.plugin ?? []) claimed.add(`ocm--${name}--${file}`)
41
+ }
42
+ }
43
+ let entries: string[]
44
+ try {
45
+ entries = readdirSync(OPENCODE_PLUGINS_DIR)
46
+ } catch {
47
+ return
48
+ }
49
+ for (const name of entries) {
50
+ if (!name.startsWith("ocm--") || claimed.has(name)) continue
51
+ const path = join(OPENCODE_PLUGINS_DIR, name)
52
+ if (fix) removePath(path, findings)
53
+ else findings.push(error(`${path}: stray ocm file — no registry entry owns it`))
54
+ }
55
+ }
56
+
57
+ export function checkBrokenLinks(registry: CoreRegistry, findings: Finding[], fix: boolean): void {
58
+ const managed = managedRoots(registry)
59
+ for (const dir of [OPENCODE_COMMANDS_DIR, OPENCODE_AGENTS_DIR, OPENCODE_PLUGINS_DIR]) {
60
+ let entries: string[]
61
+ try {
62
+ entries = readdirSync(dir)
63
+ } catch {
64
+ continue
65
+ }
66
+ for (const name of entries) {
67
+ const path = join(dir, name)
68
+ let target: string
69
+ try {
70
+ target = readlinkSync(path)
71
+ } catch {
72
+ continue
73
+ }
74
+ if (existsSync(path)) continue
75
+ if (!managed.some((root) => insideDir(target, root))) {
76
+ findings.push(error(`${path}: broken symlink → ${target} (not ocm's, left in place)`))
77
+ } else if (fix) {
78
+ removePath(path, findings)
79
+ } else {
80
+ findings.push(error(`${path}: broken symlink → ${target}`))
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // a skill mirror exists only when the source SKILL.md renders (has a name in
87
+ // its frontmatter); one that does not is legitimately skipped by materialize
88
+ function skillRenders(source: string): boolean {
89
+ try {
90
+ const content = readFileSync(join(source, "SKILL.md"), "utf8")
91
+ if (!content.startsWith("---\n")) return false
92
+ const close = content.indexOf("\n---\n", 3)
93
+ if (close === -1) return false
94
+ return /^name:[^\n]*\S/m.test(content.slice(4, close))
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ // plugin-js links and mcp keys are trust-gated, so their absence is not drift
101
+ export function checkMaterialized(registry: CoreRegistry, findings: Finding[], fix: boolean): void {
102
+ for (const [name, entry] of Object.entries(registry.marketplaces)) {
103
+ const root = componentRoot(entry)
104
+ if (!existsSync(root)) continue
105
+ const enabled = enabledPlugins(entry, root)
106
+ let missing = 0
107
+ for (const plugin of discoverPlugins(root)) {
108
+ if (enabled !== null && !enabled.has(plugin.name)) continue
109
+ if (entry.plugins[plugin.name]?.collision) continue
110
+ for (const file of plugin.components.command ?? []) {
111
+ if (!existsSync(join(OPENCODE_COMMANDS_DIR, `${plugin.name}:${file}`))) missing += 1
112
+ }
113
+ for (const file of plugin.components.agent ?? []) {
114
+ if (!existsSync(join(OPENCODE_AGENTS_DIR, `${plugin.name}:${file}`))) missing += 1
115
+ }
116
+ for (const rel of plugin.components.skill ?? []) {
117
+ const source = ["skills", "skill"].map((dir) => join(plugin.dir, dir, rel)).find((path) => existsSync(path))
118
+ const mirror = join(OCM_LINKS_DIR, name, "skills", `${plugin.name}--${rel.split("/").join("-")}`)
119
+ if (source && skillRenders(source) && !existsSync(join(mirror, "SKILL.md"))) missing += 1
120
+ }
121
+ }
122
+ if (!missing) continue
123
+ if (!fix) {
124
+ findings.push(error(`marketplace "${name}": ${missing} materialized component(s) missing (ocm update)`))
125
+ continue
126
+ }
127
+ materializeLinks(name, entry)
128
+ findings.push(fixed(`marketplace "${name}": re-materialized ${missing} component(s) (restart opencode to activate)`))
129
+ }
130
+ }
131
+
132
+ // spec 04: ocm never writes under ~/.claude or ~/.agents; an ocm-created
133
+ // symlink there should be impossible, so it is reported loudly
134
+ // spec 00: opencode also scans .claude / .agents directories walking up
135
+ // from cwd, so those bases are checked alongside the home ones
136
+ function forbiddenBases(): Set<string> {
137
+ const bases = new Set([join(HOME, ".claude"), join(HOME, ".agents")])
138
+ let dir = process.cwd()
139
+ for (;;) {
140
+ bases.add(join(dir, ".claude"))
141
+ bases.add(join(dir, ".agents"))
142
+ const parent = dirname(dir)
143
+ if (parent === dir) return bases
144
+ dir = parent
145
+ }
146
+ }
147
+
148
+ export function checkForbiddenPaths(registry: CoreRegistry, findings: Finding[]): void {
149
+ const managed = [...managedRoots(registry), OCM_LINKS_DIR]
150
+ for (const base of forbiddenBases()) {
151
+ let paths: string[]
152
+ try {
153
+ paths = readdirSync(base, { recursive: true }) as string[]
154
+ } catch {
155
+ continue
156
+ }
157
+ for (const rel of paths) {
158
+ const path = join(base, rel)
159
+ let target: string
160
+ try {
161
+ target = readlinkSync(path)
162
+ } catch {
163
+ continue
164
+ }
165
+ if (managed.some((root) => insideDir(target, root))) {
166
+ findings.push(error(`${path}: ocm-created path under a directory ocm never owns — report this bug`))
167
+ }
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,144 @@
1
+ // spec 12 `ocm doctor`: read-mostly diagnosis of an installation. The probe
2
+ // runs last, after any fixes, so its report reflects the repaired state.
3
+ import { spawnSync } from "node:child_process"
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { componentRoot, isGitRepo, readRegistry } from "../../loader/core.js"
6
+ import type { CoreRegistry } from "../../loader/core.js"
7
+ import { installLoader, loaderStatus, type LoaderFileStatus } from "../loader"
8
+ import { OCM_LEGACY_REGISTRY_FILE, OCM_LOADER_NAME, OCM_REGISTRY_FILE } from "../paths"
9
+ import { error, fixed, reportFindings, warning, type Finding } from "../findings"
10
+ import { ocmPluginErrors } from "../probe"
11
+ import { checkConfig } from "./doctor-config"
12
+ import { checkBrokenLinks, checkForbiddenPaths, checkMaterialized, checkStrays } from "./doctor-links"
13
+
14
+ function errText(err: unknown): string {
15
+ return err instanceof Error ? err.message : String(err)
16
+ }
17
+
18
+ export function doctor(fix: boolean): void {
19
+ console.log("doctor")
20
+ const findings: Finding[] = []
21
+ const registry = readRegistry()
22
+ checkGitPath(findings)
23
+ checkLoader(findings, fix)
24
+ // a registry that cannot be honored must never be read as "nothing owns
25
+ // these files": the stray and MCP fixes stay report-only until it is
26
+ // fixed, or --fix would uninstall everything at once
27
+ const registryUsable = checkRegistryFile(findings)
28
+ checkStrays(registry, findings, fix && registryUsable)
29
+ checkMarketplaces(registry, findings)
30
+ checkBrokenLinks(registry, findings, fix)
31
+ checkConfig(findings, registry, fix, registryUsable)
32
+ checkMaterialized(registry, findings, fix)
33
+ checkForbiddenPaths(registry, findings)
34
+ for (const line of ocmPluginErrors()) findings.push(error(`${line} (ocm update)`))
35
+ if (reportFindings(findings)) process.exitCode = 1
36
+ }
37
+
38
+ function checkGitPath(findings: Finding[]): void {
39
+ const run = spawnSync("git", ["--version"], { encoding: "utf8", timeout: 10_000 })
40
+ if (run.error || run.status !== 0) {
41
+ findings.push(warning("git is not on PATH — ocm update and the loader's sync need it"))
42
+ }
43
+ }
44
+
45
+ // the report names the loader trio; the remaining installed files surface
46
+ // only when broken
47
+ function checkLoader(findings: Finding[], fix: boolean): void {
48
+ let files: LoaderFileStatus[]
49
+ try {
50
+ files = loaderStatus()
51
+ } catch (err) {
52
+ findings.push(error(`cannot check the loader — ${errText(err)}`))
53
+ return
54
+ }
55
+ for (const file of files) {
56
+ if ([OCM_LOADER_NAME, "core.js", "ui.js"].includes(file.file)) {
57
+ console.log(` loader ${file.file} (${file.state})`)
58
+ }
59
+ }
60
+ const broken = files.filter((file) => file.state !== "current")
61
+ if (!broken.length) return
62
+ if (fix) {
63
+ try {
64
+ installLoader()
65
+ findings.push(fixed(`reinstalled ${broken.length} loader file(s) (restart opencode to activate)`))
66
+ } catch (err) {
67
+ findings.push(error(`cannot reinstall the loader — ${errText(err)}`))
68
+ }
69
+ return
70
+ }
71
+ for (const file of broken) {
72
+ findings.push(error(file.state === "missing"
73
+ ? `${file.file}: not installed (ocm init)`
74
+ : `${file.file}: stale version comment — a stale core silently no-ops (ocm update)`))
75
+ }
76
+ }
77
+
78
+ // returns whether the on-disk registry can be honored; a missing file is a
79
+ // genuinely empty registry, but an unusable one must gate the fixes that
80
+ // trust its ownership records
81
+ function checkRegistryFile(findings: Finding[]): boolean {
82
+ let raw: string
83
+ try {
84
+ raw = readFileSync(OCM_REGISTRY_FILE, "utf8")
85
+ } catch {
86
+ return checkLegacyRegistryFile(findings)
87
+ }
88
+ let parsed: unknown
89
+ try {
90
+ parsed = JSON.parse(raw)
91
+ } catch {
92
+ findings.push(error(`${OCM_REGISTRY_FILE}: not valid JSON (ocm update)`))
93
+ return false
94
+ }
95
+ if (typeof parsed !== "object" || parsed === null || (parsed as Record<string, unknown>).version !== 2) {
96
+ findings.push(error(`${OCM_REGISTRY_FILE}: not a current v2 registry (ocm update)`))
97
+ return false
98
+ }
99
+ return true
100
+ }
101
+
102
+ // readRegistry falls back to the pre-02 plugins/ocm-registry.json when the
103
+ // v2 file is absent and silently swallows a parse failure — the same
104
+ // lost-ownership failure mode, so it gates the registry-trusting fixes too
105
+ function checkLegacyRegistryFile(findings: Finding[]): boolean {
106
+ let raw: string
107
+ try {
108
+ raw = readFileSync(OCM_LEGACY_REGISTRY_FILE, "utf8")
109
+ } catch {
110
+ return true
111
+ }
112
+ let parsed: unknown
113
+ try {
114
+ parsed = JSON.parse(raw)
115
+ } catch {
116
+ findings.push(error(`${OCM_LEGACY_REGISTRY_FILE}: not valid JSON (ocm update)`))
117
+ return false
118
+ }
119
+ if (typeof parsed !== "object" || parsed === null) {
120
+ findings.push(error(`${OCM_LEGACY_REGISTRY_FILE}: not a JSON object (ocm update)`))
121
+ return false
122
+ }
123
+ return true
124
+ }
125
+
126
+ function checkMarketplaces(registry: CoreRegistry, findings: Finding[]): void {
127
+ for (const [name, entry] of Object.entries(registry.marketplaces)) {
128
+ const root = componentRoot(entry)
129
+ if (!existsSync(root)) {
130
+ findings.push(error(`marketplace "${name}": directory missing (${root}) — ocm update re-clones`))
131
+ continue
132
+ }
133
+ if (!entry.local && !isGitRepo(entry.dir)) {
134
+ findings.push(error(`marketplace "${name}": not a git repository (ocm update)`))
135
+ }
136
+ const lastSync = entry.lastSync
137
+ if (lastSync?.ok === false) {
138
+ const age = Date.now() - Date.parse(lastSync.at)
139
+ const minutes = Number.isFinite(age) ? Math.max(0, Math.round(age / 60_000)) : 0
140
+ const first = lastSync.error?.split("\n").map((line) => line.trim()).find(Boolean) ?? "unknown error"
141
+ findings.push(error(`marketplace "${name}": last sync failed ${minutes}m ago: ${first}`))
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,159 @@
1
+ import { join } from "node:path"
2
+ import { OCM_LINKS_DIR, OPENCODE_AGENTS_DIR, OPENCODE_COMMANDS_DIR, OPENCODE_GLOBAL_CONFIG, OPENCODE_PLUGINS_DIR } from "../paths"
3
+ import { loadRegistry } from "../registry"
4
+ import type { MarketplaceEntry, MarketplacePlugin, PluginManifest, Registry } from "../types"
5
+
6
+ export interface InfoOptions {
7
+ json?: boolean
8
+ }
9
+
10
+ interface Resolved {
11
+ marketplace: string
12
+ plugin: string
13
+ entry: MarketplaceEntry
14
+ record: MarketplacePlugin
15
+ }
16
+
17
+ // resolution never touches the marketplace directory: info answers from the
18
+ // registry cache and works for disabled and blocked plugins (spec 09)
19
+ function resolve(registry: Registry, arg: string): Resolved {
20
+ const at = arg.indexOf("@")
21
+ const plugin = at === -1 ? arg : arg.slice(0, at)
22
+ const wanted = at === -1 ? null : arg.slice(at + 1)
23
+ if (!plugin) throw new Error(`missing plugin name in "${arg}" (ocm search <query>)`)
24
+ const providers = Object.entries(registry.marketplaces).filter(
25
+ ([name, entry]) => entry.plugins[plugin] && (wanted === null || name === wanted),
26
+ )
27
+ if (!providers.length) {
28
+ throw new Error(`plugin "${plugin}" not found in any marketplace (ocm search <query>, or ocm update)`)
29
+ }
30
+ if (providers.length > 1) {
31
+ const names = providers.map(([name]) => name).join(", ")
32
+ throw new Error(`plugin "${plugin}" is provided by more than one marketplace: ${names} (use ${plugin}@<marketplace>)`)
33
+ }
34
+ const [marketplace, entry] = providers[0]!
35
+ return { marketplace, plugin, entry, record: entry.plugins[plugin]! }
36
+ }
37
+
38
+ interface ResultingComponent {
39
+ type: string
40
+ name: string
41
+ source: string
42
+ target: string
43
+ }
44
+
45
+ // the resulting opencode names, not the source filenames: "what do I type to
46
+ // use this" is the question info exists to answer (spec 09)
47
+ function resultingComponents(marketplace: string, plugin: string, record: MarketplacePlugin): ResultingComponent[] {
48
+ const out: ResultingComponent[] = []
49
+ for (const file of record.components.command ?? []) {
50
+ out.push({
51
+ type: "command",
52
+ name: `${plugin}:${file.replace(/\.md$/, "")}`,
53
+ source: join(record.source, "commands", file),
54
+ target: join(OPENCODE_COMMANDS_DIR, `${plugin}:${file}`),
55
+ })
56
+ }
57
+ for (const file of record.components.agent ?? []) {
58
+ out.push({
59
+ type: "agent",
60
+ name: `${plugin}:${file.replace(/\.md$/, "")}`,
61
+ source: join(record.source, "agents", file),
62
+ target: join(OPENCODE_AGENTS_DIR, `${plugin}:${file}`),
63
+ })
64
+ }
65
+ for (const rel of record.components.skill ?? []) {
66
+ out.push({
67
+ type: "skill",
68
+ name: `${plugin}:${rel}`,
69
+ source: join(record.source, "skills", rel, "SKILL.md"),
70
+ target: join(OCM_LINKS_DIR, marketplace, "skills", `${plugin}--${rel.split("/").join("-")}`, "SKILL.md"),
71
+ })
72
+ }
73
+ for (const file of record.components.plugin ?? []) {
74
+ out.push({
75
+ type: "plugin",
76
+ name: `ocm--${plugin}--${file}`,
77
+ source: join(record.source, "plugin", file),
78
+ target: join(OPENCODE_PLUGINS_DIR, `ocm--${plugin}--${file}`),
79
+ })
80
+ }
81
+ for (const server of record.components.mcp ?? []) {
82
+ out.push({
83
+ type: "mcp",
84
+ name: `ocm--${plugin}--${server}`,
85
+ source: join(record.source, "mcp.json"),
86
+ target: `${OPENCODE_GLOBAL_CONFIG} (mcp)`,
87
+ })
88
+ }
89
+ return out
90
+ }
91
+
92
+ // origin is debugging information, not decoration: annotated only where the
93
+ // two manifests disagreed, and the marketplace entry won by precedence
94
+ function origin(manifest: PluginManifest, field: string): string | undefined {
95
+ return manifest.conflicts?.includes(field) ? "marketplace.json" : undefined
96
+ }
97
+
98
+ function field(label: string, value: string | undefined, note?: string): void {
99
+ if (value === undefined) return
100
+ console.log(` ${label.padEnd(13)}${value}${note ? ` (${note})` : ""}`)
101
+ }
102
+
103
+ function age(iso: string): string {
104
+ const ms = Date.now() - Date.parse(iso)
105
+ if (Number.isNaN(ms) || ms < 60_000) return "just now"
106
+ const minutes = Math.floor(ms / 60_000)
107
+ if (minutes < 60) return `${minutes}m ago`
108
+ const hours = Math.floor(minutes / 60)
109
+ return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
110
+ }
111
+
112
+ export function info(arg: string, options: InfoOptions = {}): void {
113
+ const { marketplace, plugin, entry, record } = resolve(loadRegistry(), arg)
114
+ const manifest = record.manifest
115
+ if (options.json) {
116
+ console.log(JSON.stringify({
117
+ plugin,
118
+ marketplace,
119
+ description: manifest.description ?? null,
120
+ version: record.version,
121
+ category: manifest.category ?? null,
122
+ tags: manifest.tags ?? null,
123
+ keywords: manifest.keywords ?? null,
124
+ homepage: manifest.homepage ?? null,
125
+ license: manifest.license ?? null,
126
+ enabled: record.enabled,
127
+ installedAt: record.installedAt,
128
+ url: entry.url,
129
+ ref: entry.ref,
130
+ revision: entry.revision,
131
+ trust: entry.trust.code,
132
+ lastSync: entry.lastSync,
133
+ components: resultingComponents(marketplace, plugin, record),
134
+ }, null, 2))
135
+ return
136
+ }
137
+ console.log(`${plugin} @ ${marketplace}`)
138
+ field("description", manifest.description, origin(manifest, "description"))
139
+ field("version", record.version ?? undefined, origin(manifest, "version"))
140
+ field("category", manifest.category, origin(manifest, "category"))
141
+ field("tags", manifest.tags?.join(", "))
142
+ field("homepage", manifest.homepage)
143
+ field("license", manifest.license)
144
+ field("enabled", record.enabled ? "yes" : "no")
145
+ field("installed", record.installedAt ?? undefined)
146
+ field("marketplace", `${entry.url}${entry.ref ? ` @ ${entry.ref}` : ""}`)
147
+ if (entry.revision) {
148
+ const synced = entry.lastSync ? ` (synced ${age(entry.lastSync.at)})` : ""
149
+ field("revision", `${entry.revision.slice(0, 7)}${synced}`)
150
+ }
151
+ field("trust", entry.trust.code)
152
+ const components = resultingComponents(marketplace, plugin, record)
153
+ if (components.length) {
154
+ console.log(" components")
155
+ for (const component of components) {
156
+ console.log(` ${component.type.padEnd(8)}${component.name} → ${component.target}`)
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,44 @@
1
+ import { loadRegistry } from "../registry"
2
+
3
+ export interface ListOptions {
4
+ all?: boolean
5
+ json?: boolean
6
+ }
7
+
8
+ export function list(options: ListOptions = {}): void {
9
+ const registry = loadRegistry()
10
+ if (options.json) {
11
+ console.log(JSON.stringify(registry, null, 2))
12
+ return
13
+ }
14
+ const entries = Object.entries(registry.marketplaces)
15
+ if (!entries.length) {
16
+ console.log("no marketplaces added yet (ocm add <url|path>)")
17
+ return
18
+ }
19
+ for (const [name, entry] of entries) {
20
+ console.log(`${name}${options.all ? ` (${entry.mode})` : ""}`)
21
+ console.log(` source: ${entry.url}`)
22
+ // display shortens; the registry keeps the full sha (spec 08)
23
+ const short = entry.revision ? entry.revision.slice(0, 7) : null
24
+ if (short) console.log(` revision: ${short}`)
25
+ if (options.all && entry.lastSync && !entry.lastSync.ok) {
26
+ console.log(` \x1b[31mlast sync failed: ${entry.lastSync.error}\x1b[0m`)
27
+ }
28
+ for (const [pluginName, plugin] of Object.entries(entry.plugins)) {
29
+ if (!options.all && !plugin.enabled) continue
30
+ const parts: string[] = []
31
+ if (plugin.components.agent) parts.push(`agents: ${plugin.components.agent.join(", ")}`)
32
+ if (plugin.components.command) parts.push(`commands: ${plugin.components.command.join(", ")}`)
33
+ if (plugin.components.skill) parts.push(`skills: ${plugin.components.skill.join(", ")}`)
34
+ if (plugin.components.plugin) parts.push(`plugins: ${plugin.components.plugin.join(", ")}`)
35
+ if (plugin.components.mcp) parts.push(`mcp: ${plugin.components.mcp.join(", ")}`)
36
+ // the marketplace revision is the implicit version of a versionless
37
+ // plugin (spec 08)
38
+ const version = plugin.version ?? (short ? `@${short}` : null)
39
+ const markers = `${version ? ` (${version})` : ""}${options.all && !plugin.enabled ? " (disabled)" : ""}`
40
+ console.log(` ${pluginName}${markers}`)
41
+ for (const part of parts) console.log(` ${part}`)
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,99 @@
1
+ import { addMarketplace, denyTrust, grantTrust, isGitUrl, normaliseMarketplaceName, parseSource, pinMarketplace, readRegistry, removeMarketplace } from "../../loader/core.js"
2
+ import type { CoreAddResult } from "../../loader/core.js"
3
+ import { installLoader } from "../loader"
4
+ import { reportRestart, reportUpgrade, reportWarnings } from "../report"
5
+ import { promptTrust } from "./trust"
6
+
7
+ export interface AddOptions {
8
+ explicit?: boolean
9
+ name?: string
10
+ ref?: string
11
+ trust?: boolean
12
+ }
13
+
14
+ // spec 05 add, minus the dialog: the core registers, decides trust from the
15
+ // flag, saves and materializes; the CLI renders and prompts (spec 10a)
16
+ export async function add(source: string, options: AddOptions = {}): Promise<void> {
17
+ // the clone line must not state a false fact: the core refuses an
18
+ // already-added name before it clones
19
+ if (isGitUrl(source)) {
20
+ const parsed = parseSource(source)
21
+ const wanted = options.name ? normaliseMarketplaceName(options.name) : parsed.name
22
+ if (!readRegistry().marketplaces[wanted]) console.log(`cloning ${parsed.url}...`)
23
+ }
24
+ let result: CoreAddResult
25
+ try {
26
+ result = await addMarketplace(source, options)
27
+ } catch (err) {
28
+ // a refused add still owes the user the diagnostics behind the refusal
29
+ // (spec 06); they ride the error as data — the core never prints
30
+ const warnings = (err as { warnings?: unknown }).warnings
31
+ if (Array.isArray(warnings)) reportWarnings(warnings)
32
+ throw err
33
+ }
34
+ reportWarnings(result.warnings)
35
+ // a prompted decision re-materializes: the two passes are reported as one —
36
+ // created links sum, warnings union. A grant makes pass 1's "blocked
37
+ // (untrusted)" lines false, so they do not carry over
38
+ let created = result.report.created
39
+ let warnings = result.report.warnings
40
+ if (result.trustComponents.length && options.trust === undefined) {
41
+ const decision = await promptTrust(result.name, result.dir, result.trustComponents)
42
+ if (decision === "granted" || decision === "denied") {
43
+ const second = decision === "granted" ? await grantTrust(result.name) : await denyTrust(result.name)
44
+ if (second.report) {
45
+ created += second.report.created
46
+ const carry = decision === "granted"
47
+ ? warnings.filter((warning) => !warning.startsWith("blocked (untrusted): "))
48
+ : warnings
49
+ warnings = [...new Set([...carry, ...second.report.warnings])]
50
+ }
51
+ }
52
+ }
53
+ reportWarnings(warnings)
54
+ reportRestart(created)
55
+ installLoader()
56
+ reportUpgrade(result.wasV1)
57
+ reportAdded(result)
58
+ }
59
+
60
+ function reportAdded(result: CoreAddResult): void {
61
+ console.log(`added marketplace "${result.name}"`)
62
+ for (const plugin of result.plugins) {
63
+ const parts: string[] = []
64
+ if (plugin.components.agent) parts.push(`${plugin.components.agent.length} agents`)
65
+ if (plugin.components.command) parts.push(`${plugin.components.command.length} commands`)
66
+ if (plugin.components.skill) parts.push(`${plugin.components.skill.length} skills`)
67
+ if (plugin.components.plugin) parts.push(`${plugin.components.plugin.length} plugins`)
68
+ if (plugin.components.mcp) parts.push(`${plugin.components.mcp.length} mcp servers`)
69
+ const available = result.mode === "explicit" ? " — available, not installed" : ""
70
+ console.log(` ${plugin.name} (${parts.join(", ")})${available}`)
71
+ }
72
+ console.log("commands and agents are available as /<plugin>:<name> in every project")
73
+ }
74
+
75
+ export function remove(name: string): void {
76
+ const result = removeMarketplace(name)
77
+ reportWarnings(result.warnings)
78
+ reportUpgrade(result.wasV1)
79
+ console.log(`removed marketplace "${result.name}"`)
80
+ for (const plugin of result.owned) {
81
+ const parts: string[] = []
82
+ if (plugin.components.agent) parts.push(`${plugin.components.agent.length} agents`)
83
+ if (plugin.components.command) parts.push(`${plugin.components.command.length} commands`)
84
+ if (plugin.components.skill) parts.push(`${plugin.components.skill.length} skills`)
85
+ if (plugin.components.plugin) parts.push(`${plugin.components.plugin.length} plugins`)
86
+ if (plugin.components.mcp) parts.push(`${plugin.components.mcp.length} mcp servers`)
87
+ console.log(` ${plugin.name}: ${parts.join(", ")} removed`)
88
+ }
89
+ }
90
+
91
+ // spec 08: pinning is branch- and tag-following, never commit-freezing; the
92
+ // core validates the ref by fetching it before saving it
93
+ export async function pin(name: string, ref?: string, clear = false): Promise<void> {
94
+ const result = await pinMarketplace(name, clear ? null : ref)
95
+ reportUpgrade(result.wasV1)
96
+ console.log(clear
97
+ ? `marketplace "${name}" unpinned (following the default branch)`
98
+ : `marketplace "${name}" pinned to ${ref}`)
99
+ }