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