From 92f044b586161d9de88556d61e313e67f7fd43fe Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 00:21:35 +0300 Subject: [PATCH 1/7] Created poc for opencode marketplace support --- packages/opencode/src/cli/cmd/marketplace.ts | 161 ++++++++++++++++++ packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/index.ts | 2 + packages/opencode/src/marketplace/index.ts | 127 ++++++++++++++ packages/opencode/src/marketplace/install.ts | 153 +++++++++++++++++ packages/opencode/src/marketplace/registry.ts | 114 +++++++++++++ packages/opencode/src/marketplace/source.ts | 116 +++++++++++++ packages/opencode/src/marketplace/types.ts | 45 +++++ 8 files changed, 720 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/marketplace.ts create mode 100644 packages/opencode/src/marketplace/index.ts create mode 100644 packages/opencode/src/marketplace/install.ts create mode 100644 packages/opencode/src/marketplace/registry.ts create mode 100644 packages/opencode/src/marketplace/source.ts create mode 100644 packages/opencode/src/marketplace/types.ts diff --git a/packages/opencode/src/cli/cmd/marketplace.ts b/packages/opencode/src/cli/cmd/marketplace.ts new file mode 100644 index 000000000000..698b58589356 --- /dev/null +++ b/packages/opencode/src/cli/cmd/marketplace.ts @@ -0,0 +1,161 @@ +import { Effect } from "effect" +import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" +import { Marketplace } from "@/marketplace" +import { UI } from "../ui" + +export const MarketplaceCommand = cmd({ + command: "marketplace", + aliases: ["market", "registry"], + describe: "discover, install, and manage opencode packages", + builder: (yargs) => + yargs + .command(MarketplaceSearchCommand) + .command(MarketplaceInstallCommand) + .command(MarketplaceUninstallCommand) + .command(MarketplaceListCommand) + .command(MarketplaceInfoCommand) + .demandCommand(), + async handler() {}, +}) + +const MarketplaceSearchCommand = effectCmd({ + command: "search [query]", + describe: "search available packages", + instance: false, + builder: (yargs) => + yargs.positional("query", { + type: "string", + describe: "search query", + }), + handler: Effect.fn("Cli.marketplace.search")(function* (args) { + const svc = yield* Marketplace.Service + const query = String(args.query ?? "") + const results = yield* svc.search(query) + if (results.length === 0) { + process.stdout.write("No packages found.\n") + return + } + for (const pkg of results) { + const src = pkg.source as { type: string; repo?: string; url?: string; path?: string } + process.stdout.write(`${pkg.name}\n`) + if (pkg.description) process.stdout.write(` ${pkg.description}\n`) + process.stdout.write(` source: ${sourceLabel(src)}\n`) + if (pkg.version) process.stdout.write(` version: ${pkg.version}\n`) + process.stdout.write("\n") + } + }), +}) + +const MarketplaceInstallCommand = effectCmd({ + command: "install ", + aliases: ["i", "add"], + describe: "install a package from a source", + instance: false, + builder: (yargs) => + yargs + .positional("source", { + type: "string", + describe: "package source (github:user/repo, url, or local path)", + }) + .option("name", { + type: "string", + alias: "n", + describe: "package name (default: auto-detected)", + }), + handler: Effect.fn("Cli.marketplace.install")(function* (args) { + const raw = String(args.source ?? "") + if (!raw) return yield* fail("source is required (e.g. github:user/repo, url, or path)") + + const sourceStr = raw.startsWith("github:") || raw.startsWith("gh:") + ? raw + : raw.startsWith("http://") || raw.startsWith("https://") + ? raw + : raw + + const pkgName = String(args.name ?? sourceStr.split("/").pop() ?? sourceStr.split(":").pop() ?? "package") + + const svc = yield* Marketplace.Service + yield* svc.install(pkgName, sourceStr) + process.stdout.write(`Installed "${pkgName}" from ${sourceStr}\n`) + }), +}) + +const MarketplaceUninstallCommand = effectCmd({ + command: "uninstall ", + aliases: ["remove", "rm"], + describe: "uninstall a package", + instance: false, + builder: (yargs) => + yargs.positional("name", { + type: "string", + describe: "package name", + }), + handler: Effect.fn("Cli.marketplace.uninstall")(function* (args) { + const name = String(args.name ?? "") + if (!name) return yield* fail("package name is required") + + const svc = yield* Marketplace.Service + yield* svc.uninstall(name) + process.stdout.write(`Uninstalled "${name}"\n`) + }), +}) + +const MarketplaceListCommand = effectCmd({ + command: "list", + aliases: ["ls"], + describe: "list installed packages", + instance: false, + handler: Effect.fn("Cli.marketplace.list")(function* () { + const svc = yield* Marketplace.Service + const list = yield* svc.list() + + if (list.length === 0) { + process.stdout.write("No packages installed.\n") + return + } + + process.stdout.write("Installed packages:\n\n") + for (const pkg of list) { + process.stdout.write(` ${pkg.name}`) + process.stdout.write(`\n source: ${pkg.sourceUrl}\n`) + process.stdout.write(` installed: ${new Date(pkg.installedAt).toISOString().slice(0, 10)}\n\n`) + } + }), +}) + +const MarketplaceInfoCommand = effectCmd({ + command: "info ", + describe: "show details about an installed package", + instance: false, + builder: (yargs) => + yargs.positional("name", { + type: "string", + describe: "package name", + }), + handler: Effect.fn("Cli.marketplace.info")(function* (args) { + const name = String(args.name ?? "") + if (!name) return yield* fail("package name is required") + + const svc = yield* Marketplace.Service + const pkg = yield* svc.info(name) + if (!pkg) return yield* fail(`package "${name}" not found`) + + process.stdout.write(`Package: ${pkg.name}\n`) + process.stdout.write(`Source: ${pkg.sourceUrl}\n`) + process.stdout.write(`Installed: ${new Date(pkg.installedAt).toISOString()}\n`) + }), +}) + +function sourceLabel(source: { type: string; repo?: string; url?: string; path?: string }) { + switch (source.type) { + case "github": + return `github:${source.repo}` + case "url": + return source.url ?? "url" + case "local": + return source.path ?? "local" + default: + return "unknown" + } +} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 30dbb4c880a4..f57cbeba7d83 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -33,6 +33,7 @@ import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" +import { Marketplace } from "@/marketplace" import { McpAuth } from "@/mcp/auth" import { Command } from "@/command" import { Truncate } from "@/tool/truncate" @@ -88,6 +89,7 @@ export const AppLayer = Layer.mergeAll( LSP.defaultLayer, MCP.defaultLayer, McpAuth.defaultLayer, + Marketplace.defaultLayer, Command.defaultLayer, Truncate.defaultLayer, ToolRegistry.defaultLayer, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..e3929c1871ec 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,7 @@ import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" +import { MarketplaceCommand } from "./cli/cmd/marketplace" import { Heap } from "./cli/heap" const args = hideBin(process.argv) @@ -100,6 +101,7 @@ const cli = yargs(args) .command(PrCommand) .command(SessionCommand) .command(PluginCommand) + .command(MarketplaceCommand) .command(DbCommand) .fail((msg, err) => { if ( diff --git a/packages/opencode/src/marketplace/index.ts b/packages/opencode/src/marketplace/index.ts new file mode 100644 index 000000000000..4e1e816a6467 --- /dev/null +++ b/packages/opencode/src/marketplace/index.ts @@ -0,0 +1,127 @@ +import path from "path" +import { Effect, Layer, Context, Schema } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import * as Registry from "./registry" +import * as Source from "./source" +import * as Install from "./install" +import { InstalledPkg, MARKETPLACE_META_FILE } from "./types" +import type { PackageEntry } from "./types" + +export interface Interface { + readonly search: (query: string) => Effect.Effect + readonly install: (pkgName: string, source: string) => Effect.Effect + readonly uninstall: (name: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly info: (name: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Marketplace") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const registrySvc = yield* Registry.Service + const sourceSvc = yield* Source.Service + const installSvc = yield* Install.Service + + const metaFile = path.join(global.state, MARKETPLACE_META_FILE) + + const readStore = (): Effect.Effect> => + Effect.gen(function* () { + const data = yield* fs.readFileStringSafe(metaFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!data) return {} + try { + const parsed = JSON.parse(data) as Record + const result: Record = {} + for (const [key, val] of Object.entries(parsed)) { + try { + const decoded = Schema.decodeUnknownSync(InstalledPkg)(val) + result[key] = decoded + } catch { + continue + } + } + return result + } catch { + return {} + } + }) + + const writeStore = (store: Record): Effect.Effect => + fs.writeWithDirs(metaFile, JSON.stringify(store, null, 2)).pipe(Effect.catch(() => Effect.void)) + + const search: Interface["search"] = (query: string) => + Effect.gen(function* () { + const all = yield* registrySvc.all() + if (!query) return all + const lower = query.toLowerCase() + return all.filter( + (p) => + p.name.toLowerCase().includes(lower) || + (p.description ?? "").toLowerCase().includes(lower), + ) + }) + + const installPkg: Interface["install"] = (pkgName: string, sourceStr: string) => + Effect.gen(function* () { + const fetched = yield* sourceSvc.fetch(pkgName, sourceStr) + yield* installSvc.install(pkgName, fetched.dir) + + const existing = yield* readStore() + existing[pkgName] = new InstalledPkg({ + name: pkgName, + source: sourceStr, + sourceUrl: fetched.sourceUrl, + installedAt: Date.now(), + installDir: fetched.dir, + }) + yield* writeStore(existing) + }) + + const uninstallPkg: Interface["uninstall"] = (name: string) => + Effect.gen(function* () { + const store = yield* readStore() + const entry = store[name] + if (!entry) return + + const assets = yield* installSvc.discover(entry.installDir) + yield* installSvc.uninstall(name, entry.installDir, assets) + + delete store[name] + yield* writeStore(store) + }) + + const list: Interface["list"] = () => + Effect.gen(function* () { + const store = yield* readStore() + return Object.values(store) + }) + + const info: Interface["info"] = (name: string) => + Effect.gen(function* () { + const store = yield* readStore() + return store[name] + }) + + return Service.of({ + search, + install: installPkg, + uninstall: uninstallPkg, + list, + info, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Registry.defaultLayer), + Layer.provide(Source.defaultLayer), + Layer.provide(Install.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), +) + +export * as Marketplace from "." diff --git a/packages/opencode/src/marketplace/install.ts b/packages/opencode/src/marketplace/install.ts new file mode 100644 index 000000000000..cfad081f1899 --- /dev/null +++ b/packages/opencode/src/marketplace/install.ts @@ -0,0 +1,153 @@ +import path from "path" +import { Effect, Layer, Context, Schema } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Glob } from "@opencode-ai/core/util/glob" + +export class Assets extends Schema.Class("Marketplace.Assets")({ + skills: Schema.Array(Schema.String), + agents: Schema.Array(Schema.String), + tools: Schema.Array(Schema.String), + commands: Schema.Array(Schema.String), + plugins: Schema.Array(Schema.String), + mcpServers: Schema.Array(Schema.String), +}) {} + +export interface InstallResult { + readonly assets: Assets + readonly targetDir: string +} + +export interface Interface { + readonly discover: (dir: string) => Effect.Effect + readonly install: (pkgName: string, installDir: string) => Effect.Effect + readonly uninstall: (pkgName: string, installDir: string, assets: Assets) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MarketplaceInstall") {} + +const scanFiles = (dir: string, pattern: string): Effect.Effect => + Effect.tryPromise({ + try: () => Glob.scan(pattern, { cwd: dir, absolute: true }), + catch: () => [] as string[], + }).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + +const readPluginDirs = (dir: string): Effect.Effect => + Effect.gen(function* () { + const pkgFiles = yield* scanFiles(dir, "package.json") + const result: string[] = [] + for (const pkgFile of pkgFiles) { + const data = yield* Effect.tryPromise({ + try: () => import("fs/promises").then((f) => f.readFile(pkgFile, "utf-8").then(JSON.parse)), + catch: () => null as Record | null, + }).pipe(Effect.catch(() => Effect.succeed(null as Record | null))) + if (data) { + const exports = data.exports as Record | undefined + if (exports?.["./server"] || exports?.["./tui"]) { + result.push(path.dirname(pkgFile)) + } + } + } + return result + }) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + + const discover: Interface["discover"] = (dir: string) => + Effect.gen(function* () { + const [allSkills, allAgents, allTools, allCommands, allPlugins, allMcp] = yield* Effect.all( + [ + scanFiles(dir, "**/SKILL.md"), + scanFiles(dir, "{agents,agent}/**/*.md"), + scanFiles(dir, "{tools,tool}/**/*.{ts,js}"), + scanFiles(dir, "{commands,command}/**/*.md"), + readPluginDirs(dir), + scanFiles(dir, "mcp.json"), + ], + { concurrency: 6 }, + ) + + return new Assets({ + skills: allSkills, + agents: allAgents, + tools: allTools, + commands: allCommands, + plugins: allPlugins, + mcpServers: allMcp, + }) + }) + + const doInstall: Interface["install"] = (pkgName: string, installDir: string) => + Effect.gen(function* () { + const assets = yield* discover(installDir) + + for (const skillPath of assets.skills) { + const rel = path.relative(installDir, skillPath) + yield* copyFile(skillPath, path.join(global.config, "skills", pkgName, rel), fs).pipe(Effect.ignore) + } + for (const agentPath of assets.agents) { + const rel = path.relative(installDir, agentPath) + yield* copyFile(agentPath, path.join(global.config, "agents", path.basename(rel)), fs).pipe(Effect.ignore) + } + for (const toolPath of assets.tools) { + const rel = path.relative(installDir, toolPath) + yield* copyFile(toolPath, path.join(global.config, "tools", path.basename(rel)), fs).pipe(Effect.ignore) + } + for (const cmdPath of assets.commands) { + const rel = path.relative(installDir, cmdPath) + yield* copyFile(cmdPath, path.join(global.config, "commands", path.basename(rel)), fs).pipe(Effect.ignore) + } + + return { assets, targetDir: installDir } + }) + + const doUninstall: Interface["uninstall"] = (pkgName: string, _installDir: string, assets: Assets) => + Effect.gen(function* () { + if (assets.skills.length > 0) { + yield* rmDir(path.join(global.config, "skills", pkgName)).pipe(Effect.ignore) + } + for (const ap of assets.agents) { + yield* rmFile(path.join(global.config, "agents", path.basename(ap))).pipe(Effect.ignore) + } + for (const tp of assets.tools) { + yield* rmFile(path.join(global.config, "tools", path.basename(tp))).pipe(Effect.ignore) + } + for (const cp of assets.commands) { + yield* rmFile(path.join(global.config, "commands", path.basename(cp))).pipe(Effect.ignore) + } + }) + + return Service.of({ + discover, + install: doInstall, + uninstall: doUninstall, + }) + }), +) + +const copyFile = (src: string, dest: string, fsys: FSUtil.Interface): Effect.Effect => + Effect.gen(function* () { + const content = yield* fsys.readFileStringSafe(src).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!content) return false + yield* fsys.writeWithDirs(dest, content).pipe(Effect.catch(() => Effect.void)) + return true + }) + +const rmDir = (dir: string): Effect.Effect => + Effect.promise(() => import("fs/promises").then((f) => f.rm(dir, { recursive: true, force: true }))).pipe( + Effect.catch(() => Effect.void), + ) + +const rmFile = (file: string): Effect.Effect => + Effect.promise(() => import("fs/promises").then((f) => f.rm(file, { force: true }))).pipe( + Effect.catch(() => Effect.void), + ) + +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), +) diff --git a/packages/opencode/src/marketplace/registry.ts b/packages/opencode/src/marketplace/registry.ts new file mode 100644 index 000000000000..f4211b1ab72b --- /dev/null +++ b/packages/opencode/src/marketplace/registry.ts @@ -0,0 +1,114 @@ +import { Effect, Layer, Context, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { PackageEntry, RegistryIndex } from "./types" + +export interface Interface { + readonly fetchIndex: (url: string) => Effect.Effect + readonly scanLocal: (dir: string) => Effect.Effect + readonly all: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MarketplaceRegistry") {} + +const DEFAULT_REGISTRIES: ReadonlyArray<{ url?: string; local?: string }> = [ + { url: "https://raw.githubusercontent.com/anomalyco/opencode-registry/main/index.json" }, +] + +const decodeIndex = (data: unknown): RegistryIndex | null => { + try { + return Schema.decodeUnknownSync(RegistryIndex)(data) + } catch { + return null + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const fs = yield* FSUtil.Service + const global = yield* Global.Service + + const fetchIndex: Interface["fetchIndex"] = (url: string) => + Effect.gen(function* () { + const body = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.flatMap((res) => res.json), + Effect.catch(() => Effect.succeed(null)), + ) + if (!body) return [] + + const parsed = decodeIndex(body) + return parsed?.packages ?? [] + }) + + const scanLocal: Interface["scanLocal"] = (dir: string) => + Effect.gen(function* () { + const indexPath = dir.endsWith(".json") ? dir : `${dir}/index.json` + const exists = yield* fs.existsSafe(indexPath).pipe(Effect.catch(() => Effect.succeed(false))) + if (!exists) return [] + + const buf = yield* fs.readFileStringSafe(indexPath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!buf) return [] + + let parsed: RegistryIndex | null = null + try { + const raw = JSON.parse(buf) + parsed = decodeIndex(raw) + } catch { + return [] + } + if (!parsed) return [] + + return parsed.packages.map((pkg) => { + const src = pkg.source as Record + if (src?.type === "local") { + const p = src.path as string + const resolved = p.startsWith("/") ? p : `${dir}/${p}` + return new PackageEntry({ + ...pkg, + source: { type: "local" as const, path: resolved }, + }) + } + return pkg + }) + }) + + const all: Interface["all"] = () => + Effect.gen(function* () { + const results: PackageEntry[] = [] + + for (const reg of DEFAULT_REGISTRIES) { + if (reg.url) { + const entries = yield* fetchIndex(reg.url) + results.push(...entries) + } + if (reg.local) { + const entries = yield* scanLocal(reg.local) + results.push(...entries) + } + } + + const localRegDir = `${global.config}/registry` + const localExists = yield* fs.existsSafe(localRegDir).pipe(Effect.catch(() => Effect.succeed(false))) + if (localExists) { + const entries = yield* scanLocal(localRegDir) + results.push(...entries) + } + + return results + }) + + return Service.of({ fetchIndex, scanLocal, all }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), +) diff --git a/packages/opencode/src/marketplace/source.ts b/packages/opencode/src/marketplace/source.ts new file mode 100644 index 000000000000..1cea11dff09d --- /dev/null +++ b/packages/opencode/src/marketplace/source.ts @@ -0,0 +1,116 @@ +import path from "path" +import { Effect, Layer, Context } from "effect" +import { Global } from "@opencode-ai/core/global" +import { MARKETPLACE_CACHE_DIR } from "./types" + +export interface FetchResult { + readonly dir: string + readonly sourceUrl: string +} + +export interface Interface { + readonly fetch: (pkgName: string, source: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MarketplaceSource") {} + +const cloneGitHub = ( + pkgName: string, + source: string, + cacheRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const repo = source.replace(/^(github:|gh:)/, "") + const dest = path.join(cacheRoot, pkgName) + + const exists = yield* Effect.promise(() => + import("fs/promises").then((f) => f.stat(dest).then(() => true).catch(() => false)), + ) + if (exists) return { dir: dest, sourceUrl: `https://github.com/${repo}` } + + const url = `https://github.com/${repo}.git` + const result = yield* Effect.promise(() => + import("@/util/process").then(({ Process }) => + Process.run(["git", "clone", "--depth", "1", url, dest], { nothrow: true }), + ), + ) + if (result.code === 0) return { dir: dest, sourceUrl: `https://github.com/${repo}` } + return yield* Effect.die(new Error(`Failed to clone ${url}`)) + }).pipe(Effect.catch(() => Effect.succeed({ dir: "", sourceUrl: "" } as FetchResult))) + +const downloadUrl = ( + pkgName: string, + url: string, + cacheRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const dest = path.join(cacheRoot, pkgName) + + const exists = yield* Effect.promise(() => + import("fs/promises").then((f) => f.stat(dest).then(() => true).catch(() => false)), + ) + if (exists) return { dir: dest, sourceUrl: url } + + const buf = yield* Effect.promise((): Promise => + globalThis.fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null)).catch(() => null), + ) + if (!buf) return yield* Effect.die(new Error(`Failed to download ${url}`)) + + const tmp = `${dest}.tmp` + yield* Effect.promise(() => import("fs/promises").then((f) => f.writeFile(tmp, Buffer.from(buf)))) + yield* Effect.promise(() => import("fs/promises").then((f) => f.mkdir(dest, { recursive: true }))) + yield* Effect.promise(async () => { + const { execSync } = await import("child_process") + try { + execSync(`tar -xzf "${tmp}" -C "${dest}" 2>/dev/null`, { stdio: "ignore" }) + } catch { + execSync(`unzip -o "${tmp}" -d "${dest}" 2>/dev/null`, { stdio: "ignore" }) + } + }) + yield* Effect.promise(() => import("fs/promises").then((f) => f.unlink(tmp))) + return { dir: dest, sourceUrl: url } + }).pipe(Effect.catch(() => Effect.succeed({ dir: "", sourceUrl: "" } as FetchResult))) + +const copyLocal = ( + pkgName: string, + filePath: string, + cacheRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const dest = path.join(cacheRoot, pkgName) + + const exists = yield* Effect.promise(() => + import("fs/promises").then((f) => f.stat(dest).then(() => true).catch(() => false)), + ) + if (exists) return { dir: dest, sourceUrl: filePath } + + yield* Effect.promise(() => import("fs/promises").then((f) => f.mkdir(dest, { recursive: true }))) + const resolved = filePath.startsWith("/") ? filePath : path.resolve(filePath) + yield* Effect.promise(() => import("fs/promises").then((f) => f.cp(resolved, dest, { recursive: true }))) + return { dir: dest, sourceUrl: resolved } + }).pipe(Effect.catch(() => Effect.succeed({ dir: "", sourceUrl: "" } as FetchResult))) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const global = yield* Global.Service + const cacheRoot = path.join(global.cache, MARKETPLACE_CACHE_DIR) + + const fetch: Interface["fetch"] = (pkgName: string, source: string) => + Effect.gen(function* () { + if (source.startsWith("github:") || source.startsWith("gh:")) { + return yield* cloneGitHub(pkgName, source, cacheRoot) + } + if (source.startsWith("http://") || source.startsWith("https://")) { + return yield* downloadUrl(pkgName, source, cacheRoot) + } + return yield* copyLocal(pkgName, source, cacheRoot) + }) + + return Service.of({ fetch }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Global.defaultLayer), +) diff --git a/packages/opencode/src/marketplace/types.ts b/packages/opencode/src/marketplace/types.ts new file mode 100644 index 000000000000..69c51f13a472 --- /dev/null +++ b/packages/opencode/src/marketplace/types.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" + +export class GitHubSource extends Schema.Class("Marketplace.GitHubSource")({ + type: Schema.Literal("github"), + repo: Schema.String, + ref: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), +}) {} + +export class UrlSource extends Schema.Class("Marketplace.UrlSource")({ + type: Schema.Literal("url"), + url: Schema.String, +}) {} + +export class LocalSource extends Schema.Class("Marketplace.LocalSource")({ + type: Schema.Literal("local"), + path: Schema.String, +}) {} + +export type Source = { type: string; repo?: string; url?: string; path?: string; ref?: string } + +export class PackageEntry extends Schema.Class("Marketplace.PackageEntry")({ + name: Schema.String, + description: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + source: Schema.Unknown, +}) {} + +export class RegistryIndex extends Schema.Class("Marketplace.RegistryIndex")({ + packages: Schema.Array(PackageEntry), +}) {} + +export class InstalledPkg extends Schema.Class("Marketplace.InstalledPkg")({ + name: Schema.String, + version: Schema.optional(Schema.String), + source: Schema.Unknown, + sourceUrl: Schema.String, + installedAt: Schema.Number, + installDir: Schema.String, +}) {} + +export type InstalledStore = Record> + +export const MARKETPLACE_META_FILE = "marketplace.json" +export const MARKETPLACE_CACHE_DIR = "marketplace" From 601824dd26a29e4d495d702fbed577e132adadab Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 18:28:20 +0300 Subject: [PATCH 2/7] bugfix - empty directories prevented future installations --- packages/opencode/src/marketplace/source.ts | 42 +++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/marketplace/source.ts b/packages/opencode/src/marketplace/source.ts index 1cea11dff09d..ec6956650c72 100644 --- a/packages/opencode/src/marketplace/source.ts +++ b/packages/opencode/src/marketplace/source.ts @@ -23,10 +23,16 @@ const cloneGitHub = ( const repo = source.replace(/^(github:|gh:)/, "") const dest = path.join(cacheRoot, pkgName) - const exists = yield* Effect.promise(() => - import("fs/promises").then((f) => f.stat(dest).then(() => true).catch(() => false)), - ) - if (exists) return { dir: dest, sourceUrl: `https://github.com/${repo}` } + const valid = yield* Effect.promise(async (): Promise => { + try { + const entries = await import("fs/promises").then((f) => f.readdir(dest)) + return entries.length > 0 && entries.some((e) => e !== ".git") + } catch { + return false + } + }) + if (valid) return { dir: dest, sourceUrl: `https://github.com/${repo}` } + yield* Effect.promise(() => import("fs/promises").then((f) => f.rm(dest, { recursive: true, force: true }))) const url = `https://github.com/${repo}.git` const result = yield* Effect.promise(() => @@ -46,10 +52,16 @@ const downloadUrl = ( Effect.gen(function* () { const dest = path.join(cacheRoot, pkgName) - const exists = yield* Effect.promise(() => - import("fs/promises").then((f) => f.stat(dest).then(() => true).catch(() => false)), - ) - if (exists) return { dir: dest, sourceUrl: url } + const valid = yield* Effect.promise(async (): Promise => { + try { + const entries = await import("fs/promises").then((f) => f.readdir(dest)) + return entries.length > 0 + } catch { + return false + } + }) + if (valid) return { dir: dest, sourceUrl: url } + yield* Effect.promise(() => import("fs/promises").then((f) => f.rm(dest, { recursive: true, force: true }))) const buf = yield* Effect.promise((): Promise => globalThis.fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null)).catch(() => null), @@ -79,10 +91,16 @@ const copyLocal = ( Effect.gen(function* () { const dest = path.join(cacheRoot, pkgName) - const exists = yield* Effect.promise(() => - import("fs/promises").then((f) => f.stat(dest).then(() => true).catch(() => false)), - ) - if (exists) return { dir: dest, sourceUrl: filePath } + const valid = yield* Effect.promise(async (): Promise => { + try { + const entries = await import("fs/promises").then((f) => f.readdir(dest)) + return entries.length > 0 + } catch { + return false + } + }) + if (valid) return { dir: dest, sourceUrl: filePath } + yield* Effect.promise(() => import("fs/promises").then((f) => f.rm(dest, { recursive: true, force: true }))) yield* Effect.promise(() => import("fs/promises").then((f) => f.mkdir(dest, { recursive: true }))) const resolved = filePath.startsWith("/") ? filePath : path.resolve(filePath) From 085d4f2acc7f663151ccd60c4fbebb447f587929 Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 18:28:43 +0300 Subject: [PATCH 3/7] bugfix - skill should be downloaded with it's whole dir --- packages/opencode/src/marketplace/install.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/marketplace/install.ts b/packages/opencode/src/marketplace/install.ts index cfad081f1899..1fbbc4f9e631 100644 --- a/packages/opencode/src/marketplace/install.ts +++ b/packages/opencode/src/marketplace/install.ts @@ -86,8 +86,12 @@ export const layer = Layer.effect( const assets = yield* discover(installDir) for (const skillPath of assets.skills) { - const rel = path.relative(installDir, skillPath) - yield* copyFile(skillPath, path.join(global.config, "skills", pkgName, rel), fs).pipe(Effect.ignore) + const skillDir = path.dirname(skillPath) + const skillName = path.basename(skillDir) + const dest = path.join(global.config, "skills", pkgName, skillName) + yield* Effect.promise(() => + import("fs/promises").then((f) => f.cp(skillDir, dest, { recursive: true })), + ).pipe(Effect.ignore) } for (const agentPath of assets.agents) { const rel = path.relative(installDir, agentPath) From 84f53d835fa1646dc6b4d6d6238e9195a294944d Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 18:50:05 +0300 Subject: [PATCH 4/7] added tests --- .../test/marketplace/marketplace.test.ts | 672 ++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 packages/opencode/test/marketplace/marketplace.test.ts diff --git a/packages/opencode/test/marketplace/marketplace.test.ts b/packages/opencode/test/marketplace/marketplace.test.ts new file mode 100644 index 000000000000..8e3acc36d7f5 --- /dev/null +++ b/packages/opencode/test/marketplace/marketplace.test.ts @@ -0,0 +1,672 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import os from "os" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import * as Marketplace from "@/marketplace" +import * as Registry from "@/marketplace/registry" +import * as Source from "@/marketplace/source" +import * as Install from "@/marketplace/install" +import type { PackageEntry } from "@/marketplace/types" +import { testEffect } from "../lib/effect" + +async function writeFile(dir: string, rel: string, content: string) { + const fp = path.join(dir, rel) + await import("fs/promises").then((f) => f.mkdir(path.dirname(fp), { recursive: true })) + await import("fs/promises").then((f) => f.writeFile(fp, content)) +} + +async function mkdir(dir: string) { + await import("fs/promises").then((f) => f.mkdir(dir, { recursive: true })) +} + +function tmpdirEffect() { + return Effect.acquireRelease( + Effect.promise(async (): Promise => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + }), + (dir) => + Effect.promise(() => + import("fs/promises").then((f) => + f.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined), + ), + ), + ) +} + +function testGlobal(paths: { config: string; state: string; cache: string }) { + return Layer.succeed(Global.Service, Global.Service.of(Global.make(paths))) +} + +const fsLayer = FSUtil.defaultLayer + +describe("Registry", () => { + const it = testEffect( + Registry.layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(fsLayer), + Layer.provide(Global.defaultLayer), + ), + ) + + it.live("scanLocal returns empty for missing directory", () => + Effect.gen(function* () { + const svc = yield* Registry.Service + const result = yield* svc.scanLocal("/nonexistent") + expect(result).toEqual([]) + }), + ) + + it.live("scanLocal reads index.json with package entries", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "index.json", JSON.stringify({ + packages: [ + { name: "test-pkg", source: { type: "url", url: "https://example.com/pkg" } }, + { name: "local-pkg", source: { type: "local", path: "./skills" } }, + ], + }))) + + const svc = yield* Registry.Service + const result = yield* svc.scanLocal(dir) + expect(result).toHaveLength(2) + expect(result[0].name).toBe("test-pkg") + expect(result[1].name).toBe("local-pkg") + }), + ), + ) + + it.live("scanLocal resolves relative local paths", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "index.json", JSON.stringify({ + packages: [ + { name: "local-pkg", source: { type: "local", path: "./my-skills" } }, + ], + }))) + + const svc = yield* Registry.Service + const result = yield* svc.scanLocal(dir) + expect((result[0].source as Record).path).toContain("/my-skills") + }), + ), + ) + + it.live("scanLocal returns empty for invalid json", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "index.json", "not json")) + + const svc = yield* Registry.Service + const result = yield* svc.scanLocal(dir) + expect(result).toEqual([]) + }), + ), + ) +}) + +describe("Install", () => { + const baseLayer = Install.layer.pipe( + Layer.provide(fsLayer), + Layer.provide(Global.defaultLayer), + ) + + describe("discover", () => { + const it = testEffect(baseLayer) + + it.live("discovers SKILL.md files", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "my-skill/SKILL.md", "# My Skill")) + yield* Effect.promise(() => writeFile(dir, "another-skill/SKILL.md", "# Another Skill")) + + const svc = yield* Install.Service + const assets = yield* svc.discover(dir) + expect(assets.skills).toHaveLength(2) + expect(assets.skills[0]).toMatch(/SKILL\.md$/) + }), + ), + ) + + it.live("discovers nested SKILL.md files", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "group/my-skill/SKILL.md", "# Nested")) + yield* Effect.promise(() => writeFile(dir, "standalone/SKILL.md", "# Standalone")) + + const svc = yield* Install.Service + const assets = yield* svc.discover(dir) + expect(assets.skills).toHaveLength(2) + }), + ), + ) + + it.live("discovers agents, tools, and mcp servers", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "agents/my-agent.md", "# Agent")) + yield* Effect.promise(() => writeFile(dir, "tools/my-tool.ts", "export {}")) + yield* Effect.promise(() => writeFile(dir, "mcp.json", "{}")) + + const svc = yield* Install.Service + const assets = yield* svc.discover(dir) + expect(assets.agents).toHaveLength(1) + expect(assets.tools).toHaveLength(1) + expect(assets.mcpServers).toHaveLength(1) + }), + ), + ) + + it.live("discovers plugins via package.json exports", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => + writeFile(dir, "package.json", JSON.stringify({ exports: { "./server": "./server.js" } })), + ) + + const svc = yield* Install.Service + const assets = yield* svc.discover(dir) + expect(assets.plugins).toHaveLength(1) + }), + ), + ) + + it.live("returns empty assets for empty directory", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + const svc = yield* Install.Service + const assets = yield* svc.discover(dir) + expect(assets.skills).toEqual([]) + expect(assets.agents).toEqual([]) + expect(assets.tools).toEqual([]) + expect(assets.commands).toEqual([]) + expect(assets.plugins).toEqual([]) + expect(assets.mcpServers).toEqual([]) + }), + ), + ) + }) + + describe("install and uninstall", () => { + const it = testEffect(baseLayer) + + function installLayer(configDir: string) { + return Install.layer.pipe( + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config: configDir, state: configDir, cache: "/tmp" })), + ) + } + + async function withConfigDir(fn: (dirs: { src: string; config: string }) => Promise) { + const src = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + const config = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + try { + return await fn({ src, config }) + } finally { + await Promise.all([ + import("fs/promises").then((f) => f.rm(src, { recursive: true, force: true }).catch(() => undefined)), + import("fs/promises").then((f) => f.rm(config, { recursive: true, force: true }).catch(() => undefined)), + ]) + } + } + + test("copies full skill directory to config", async () => { + await withConfigDir(async ({ src, config }) => { + await writeFile(src, "my-skill/SKILL.md", "# Skill") + await writeFile(src, "my-skill/scripts/run.sh", "echo hello") + await writeFile(src, "my-skill/references/guide.md", "# Guide") + await mkdir(path.join(config, "skills", "test-pkg")) + + await Effect.runPromise( + Effect.gen(function* () { + const s = yield* Install.Service + const r = yield* s.install("test-pkg", src) + expect(r.assets.skills).toHaveLength(1) + }).pipe(Effect.provide( + Install.layer.pipe( + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config, state: config, cache: "/tmp" })), + ), + )), + ) + + const skillDir = path.join(config, "skills", "test-pkg", "my-skill") + const exists = await import("fs/promises").then((f) => + f.stat(skillDir).then(() => true).catch(() => false), + ) + expect(exists).toBe(true) + + const f = await import("fs/promises") + const all: string[] = [] + async function walk(dir: string, prefix: string) { + const entries = await f.readdir(dir, { withFileTypes: true }) + for (const e of entries) { + const r = prefix ? `${prefix}/${e.name}` : e.name + if (e.isDirectory()) await walk(path.join(dir, e.name), r) + else all.push(r) + } + } + await walk(skillDir, "") + expect(all).toContain("SKILL.md") + expect(all).toContain("scripts/run.sh") + expect(all).toContain("references/guide.md") + }) + }) + + test("uninstalls removes skill directory", async () => { + await withConfigDir(async ({ src, config }) => { + await writeFile(src, "my-skill/SKILL.md", "# Skill") + await mkdir(path.join(config, "skills", "test-pkg")) + + await Effect.runPromise( + Effect.gen(function* () { + const s = yield* Install.Service + const r = yield* s.install("test-pkg", src) + yield* s.uninstall("test-pkg", src, r.assets) + }).pipe(Effect.provide( + Install.layer.pipe( + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config, state: config, cache: "/tmp" })), + ), + )), + ) + + const pkgDir = path.join(config, "skills", "test-pkg") + const exists = await import("fs/promises").then((f) => + f.stat(pkgDir).then(() => true).catch(() => false), + ) + expect(exists).toBe(false) + }) + }) + }) +}) + +describe("Source", () => { + const it = testEffect( + Source.layer.pipe( + Layer.provide( + testGlobal({ config: "/tmp", state: "/tmp", cache: "/tmp" }), + ), + ), + ) + + it.live("copies a directory to cache", () => + Effect.scoped( + Effect.gen(function* () { + const srcDir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(srcDir, "skill/SKILL.md", "# Skill")) + + const svc = yield* Source.Service + const result = yield* svc.fetch("test-pkg", srcDir) + expect(result.dir).toContain("test-pkg") + expect(result.sourceUrl).toContain(srcDir) + + const files = yield* Effect.promise(() => + import("fs/promises").then((f) => f.readdir(path.join(result.dir, "skill"))), + ) + expect(files).toContain("SKILL.md") + }), + ), + ) + + it.live("reuses existing cache with content", () => + Effect.scoped( + Effect.gen(function* () { + const srcDir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(srcDir, "skill/SKILL.md", "# Skill")) + + const svc = yield* Source.Service + const first = yield* svc.fetch("test-pkg", srcDir) + const second = yield* svc.fetch("test-pkg", srcDir) + expect(first.dir).toBe(second.dir) + }), + ), + ) + + it.live("replaces stale empty cache directory", () => + Effect.scoped( + Effect.gen(function* () { + const srcDir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(srcDir, "skill/SKILL.md", "# Skill")) + + yield* Effect.promise(() => + import("fs/promises").then((f) => + f.mkdir(path.join("/tmp", "marketplace", "test-pkg"), { recursive: true }), + ).catch(() => undefined), + ) + + const svc = yield* Source.Service + const result = yield* svc.fetch("test-pkg", srcDir) + const files = yield* Effect.promise(() => + import("fs/promises").then((f) => f.readdir(result.dir)), + ) + expect(files.length).toBeGreaterThan(0) + }), + ), + ) +}) + +describe("Marketplace", () => { + describe("search", () => { + const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ + all: (query?: string) => { + const entries: PackageEntry[] = [ + { name: "test-pkg", description: "A test package", version: "1.0.0", source: { type: "github" as const, repo: "user/test-pkg" } }, + { name: "other-pkg", description: "Another package", source: { type: "url" as const, url: "https://example.com/pkg" } }, + ] + if (!query) return Effect.succeed(entries) + return Effect.succeed(entries.filter((p) => p.name.includes(query) || p.description?.includes(query))) + }, + fetchIndex: () => Effect.succeed([]), + scanLocal: () => Effect.succeed([]), + })) + + const it = testEffect( + Marketplace.layer.pipe( + Layer.provide(mockRegistry), + Layer.provide(Source.layer), + Layer.provide(Install.layer), + Layer.provide(fsLayer), + Layer.provide(Global.defaultLayer), + ), + ) + + it.effect("returns all packages without query", () => + Effect.gen(function* () { + const svc = yield* Marketplace.Service + const result = yield* svc.search("") + expect(result).toHaveLength(2) + }), + ) + + it.effect("filters by name", () => + Effect.gen(function* () { + const svc = yield* Marketplace.Service + const result = yield* svc.search("test") + expect(result).toHaveLength(1) + expect(result[0].name).toBe("test-pkg") + }), + ) + + it.effect("filters by description", () => + Effect.gen(function* () { + const svc = yield* Marketplace.Service + const result = yield* svc.search("another") + expect(result).toHaveLength(1) + expect(result[0].name).toBe("other-pkg") + }), + ) + + it.effect("returns empty for no match", () => + Effect.gen(function* () { + const svc = yield* Marketplace.Service + const result = yield* svc.search("nonexistent") + expect(result).toEqual([]) + }), + ) + }) + + describe("install, list, info, uninstall", () => { + test("install records the package", async () => { + const stateDir = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + + try { + const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ + all: () => Effect.succeed([] as PackageEntry[]), + fetchIndex: () => Effect.succeed([] as PackageEntry[]), + scanLocal: () => Effect.succeed([] as PackageEntry[]), + })) + + const mockSource = Layer.succeed(Source.Service, Source.Service.of({ + fetch: () => Effect.succeed({ dir: "/tmp/test-pkg", sourceUrl: "https://github.com/user/test-pkg" }), + })) + + const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + uninstall: () => Effect.void, + })) + + const layer = Marketplace.layer.pipe( + Layer.provide(mockRegistry), + Layer.provide(mockSource), + Layer.provide(mockInstall), + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config: "/tmp", state: stateDir, cache: "/tmp" })), + ) + + const result = await Effect.runPromise( + Effect.gen(function* () { + const svc = yield* Marketplace.Service + yield* svc.install("test-pkg", "github:user/test-pkg") + return yield* svc.list() + }).pipe(Effect.provide(layer)), + ) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("test-pkg") + } finally { + await import("fs/promises").then((f) => + f.rm(stateDir, { recursive: true, force: true }).catch(() => undefined), + ) + } + }) + + test("info returns package details", async () => { + const stateDir = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + + try { + const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ + all: () => Effect.succeed([] as PackageEntry[]), + fetchIndex: () => Effect.succeed([] as PackageEntry[]), + scanLocal: () => Effect.succeed([] as PackageEntry[]), + })) + + const mockSource = Layer.succeed(Source.Service, Source.Service.of({ + fetch: () => Effect.succeed({ dir: "/tmp/test-pkg", sourceUrl: "https://github.com/user/test-pkg" }), + })) + + const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + uninstall: () => Effect.void, + })) + + const layer = Marketplace.layer.pipe( + Layer.provide(mockRegistry), + Layer.provide(mockSource), + Layer.provide(mockInstall), + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config: "/tmp", state: stateDir, cache: "/tmp" })), + ) + + const info = await Effect.runPromise( + Effect.gen(function* () { + const svc = yield* Marketplace.Service + yield* svc.install("test-pkg", "github:user/test-pkg") + return yield* svc.info("test-pkg") + }).pipe(Effect.provide(layer)), + ) + + expect(info).toBeDefined() + expect(info!.name).toBe("test-pkg") + expect(info!.sourceUrl).toBe("https://github.com/user/test-pkg") + expect(typeof info!.installedAt).toBe("number") + } finally { + await import("fs/promises").then((f) => + f.rm(stateDir, { recursive: true, force: true }).catch(() => undefined), + ) + } + }) + + test("info returns undefined for missing package", async () => { + const stateDir = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + + try { + const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ + all: () => Effect.succeed([] as PackageEntry[]), + fetchIndex: () => Effect.succeed([] as PackageEntry[]), + scanLocal: () => Effect.succeed([] as PackageEntry[]), + })) + + const mockSource = Layer.succeed(Source.Service, Source.Service.of({ + fetch: () => Effect.succeed({ dir: "/tmp/test-pkg", sourceUrl: "https://github.com/user/test-pkg" }), + })) + + const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + uninstall: () => Effect.void, + })) + + const layer = Marketplace.layer.pipe( + Layer.provide(mockRegistry), + Layer.provide(mockSource), + Layer.provide(mockInstall), + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config: "/tmp", state: stateDir, cache: "/tmp" })), + ) + + const info = await Effect.runPromise( + Effect.gen(function* () { + const svc = yield* Marketplace.Service + return yield* svc.info("nonexistent") + }).pipe(Effect.provide(layer)), + ) + + expect(info).toBeUndefined() + } finally { + await import("fs/promises").then((f) => + f.rm(stateDir, { recursive: true, force: true }).catch(() => undefined), + ) + } + }) + + test("list returns empty with no installations", async () => { + const stateDir = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + + try { + const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ + all: () => Effect.succeed([] as PackageEntry[]), + fetchIndex: () => Effect.succeed([] as PackageEntry[]), + scanLocal: () => Effect.succeed([] as PackageEntry[]), + })) + + const mockSource = Layer.succeed(Source.Service, Source.Service.of({ + fetch: () => Effect.succeed({ dir: "/tmp/test-pkg", sourceUrl: "https://github.com/user/test-pkg" }), + })) + + const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + uninstall: () => Effect.void, + })) + + const layer = Marketplace.layer.pipe( + Layer.provide(mockRegistry), + Layer.provide(mockSource), + Layer.provide(mockInstall), + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config: "/tmp", state: stateDir, cache: "/tmp" })), + ) + + const list = await Effect.runPromise( + Effect.gen(function* () { + const svc = yield* Marketplace.Service + return yield* svc.list() + }).pipe(Effect.provide(layer)), + ) + + expect(list).toEqual([]) + } finally { + await import("fs/promises").then((f) => + f.rm(stateDir, { recursive: true, force: true }).catch(() => undefined), + ) + } + }) + + test("uninstall removes the package record", async () => { + const stateDir = await (async () => { + const d = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await import("fs/promises").then((f) => f.mkdir(d, { recursive: true })) + return await import("fs/promises").then((f) => f.realpath(d)) + })() + + try { + const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ + all: () => Effect.succeed([] as PackageEntry[]), + fetchIndex: () => Effect.succeed([] as PackageEntry[]), + scanLocal: () => Effect.succeed([] as PackageEntry[]), + })) + + const mockSource = Layer.succeed(Source.Service, Source.Service.of({ + fetch: () => Effect.succeed({ dir: "/tmp/test-pkg", sourceUrl: "https://github.com/user/test-pkg" }), + })) + + const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + uninstall: () => Effect.void, + })) + + const layer = Marketplace.layer.pipe( + Layer.provide(mockRegistry), + Layer.provide(mockSource), + Layer.provide(mockInstall), + Layer.provide(fsLayer), + Layer.provide(testGlobal({ config: "/tmp", state: stateDir, cache: "/tmp" })), + ) + + await Effect.runPromise( + Effect.gen(function* () { + const svc = yield* Marketplace.Service + yield* svc.install("test-pkg", "github:user/test-pkg") + yield* svc.uninstall("test-pkg") + const list = yield* svc.list() + expect(list).toEqual([]) + }).pipe(Effect.provide(layer)), + ) + } finally { + await import("fs/promises").then((f) => + f.rm(stateDir, { recursive: true, force: true }).catch(() => undefined), + ) + } + }) + }) +}) From cbd6c54cb2e790409d8dc217d59a5d3a08306ce0 Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 19:20:12 +0300 Subject: [PATCH 5/7] feature should support only exisiting opencode abilities --- packages/opencode/src/marketplace/install.ts | 31 ++----------------- .../test/marketplace/marketplace.test.ts | 31 ++++++++----------- 2 files changed, 16 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/marketplace/install.ts b/packages/opencode/src/marketplace/install.ts index 1fbbc4f9e631..9dcf828fc421 100644 --- a/packages/opencode/src/marketplace/install.ts +++ b/packages/opencode/src/marketplace/install.ts @@ -7,10 +7,7 @@ import { Glob } from "@opencode-ai/core/util/glob" export class Assets extends Schema.Class("Marketplace.Assets")({ skills: Schema.Array(Schema.String), agents: Schema.Array(Schema.String), - tools: Schema.Array(Schema.String), - commands: Schema.Array(Schema.String), plugins: Schema.Array(Schema.String), - mcpServers: Schema.Array(Schema.String), }) {} export interface InstallResult { @@ -59,25 +56,19 @@ export const layer = Layer.effect( const discover: Interface["discover"] = (dir: string) => Effect.gen(function* () { - const [allSkills, allAgents, allTools, allCommands, allPlugins, allMcp] = yield* Effect.all( + const [allSkills, allAgents, allPlugins] = yield* Effect.all( [ scanFiles(dir, "**/SKILL.md"), scanFiles(dir, "{agents,agent}/**/*.md"), - scanFiles(dir, "{tools,tool}/**/*.{ts,js}"), - scanFiles(dir, "{commands,command}/**/*.md"), readPluginDirs(dir), - scanFiles(dir, "mcp.json"), ], - { concurrency: 6 }, + { concurrency: 3 }, ) return new Assets({ skills: allSkills, agents: allAgents, - tools: allTools, - commands: allCommands, plugins: allPlugins, - mcpServers: allMcp, }) }) @@ -94,18 +85,8 @@ export const layer = Layer.effect( ).pipe(Effect.ignore) } for (const agentPath of assets.agents) { - const rel = path.relative(installDir, agentPath) - yield* copyFile(agentPath, path.join(global.config, "agents", path.basename(rel)), fs).pipe(Effect.ignore) + yield* copyFile(agentPath, path.join(global.config, "agents", path.basename(agentPath)), fs).pipe(Effect.ignore) } - for (const toolPath of assets.tools) { - const rel = path.relative(installDir, toolPath) - yield* copyFile(toolPath, path.join(global.config, "tools", path.basename(rel)), fs).pipe(Effect.ignore) - } - for (const cmdPath of assets.commands) { - const rel = path.relative(installDir, cmdPath) - yield* copyFile(cmdPath, path.join(global.config, "commands", path.basename(rel)), fs).pipe(Effect.ignore) - } - return { assets, targetDir: installDir } }) @@ -117,12 +98,6 @@ export const layer = Layer.effect( for (const ap of assets.agents) { yield* rmFile(path.join(global.config, "agents", path.basename(ap))).pipe(Effect.ignore) } - for (const tp of assets.tools) { - yield* rmFile(path.join(global.config, "tools", path.basename(tp))).pipe(Effect.ignore) - } - for (const cp of assets.commands) { - yield* rmFile(path.join(global.config, "commands", path.basename(cp))).pipe(Effect.ignore) - } }) return Service.of({ diff --git a/packages/opencode/test/marketplace/marketplace.test.ts b/packages/opencode/test/marketplace/marketplace.test.ts index 8e3acc36d7f5..8d5ad7fc6d02 100644 --- a/packages/opencode/test/marketplace/marketplace.test.ts +++ b/packages/opencode/test/marketplace/marketplace.test.ts @@ -150,19 +150,15 @@ describe("Install", () => { ), ) - it.live("discovers agents, tools, and mcp servers", () => + it.live("discovers agents", () => Effect.scoped( Effect.gen(function* () { const dir = yield* tmpdirEffect() yield* Effect.promise(() => writeFile(dir, "agents/my-agent.md", "# Agent")) - yield* Effect.promise(() => writeFile(dir, "tools/my-tool.ts", "export {}")) - yield* Effect.promise(() => writeFile(dir, "mcp.json", "{}")) const svc = yield* Install.Service const assets = yield* svc.discover(dir) expect(assets.agents).toHaveLength(1) - expect(assets.tools).toHaveLength(1) - expect(assets.mcpServers).toHaveLength(1) }), ), ) @@ -190,10 +186,7 @@ describe("Install", () => { const assets = yield* svc.discover(dir) expect(assets.skills).toEqual([]) expect(assets.agents).toEqual([]) - expect(assets.tools).toEqual([]) - expect(assets.commands).toEqual([]) expect(assets.plugins).toEqual([]) - expect(assets.mcpServers).toEqual([]) }), ), ) @@ -298,6 +291,7 @@ describe("Install", () => { expect(exists).toBe(false) }) }) + }) }) @@ -446,8 +440,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -495,8 +489,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -546,8 +540,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -593,8 +587,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -640,8 +634,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], tools: [], commands: [], plugins: [], mcpServers: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -668,5 +662,6 @@ describe("Marketplace", () => { ) } }) + }) }) From 5d0fa92b0fd034aeab1459c39dd22a2a4c1fe53a Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 19:47:52 +0300 Subject: [PATCH 6/7] added gitlab support --- packages/opencode/src/cli/cmd/marketplace.ts | 18 ++++----- packages/opencode/src/marketplace/index.ts | 17 ++++++--- packages/opencode/src/marketplace/install.ts | 4 +- packages/opencode/src/marketplace/source.ts | 37 ++++++++++++++++++- packages/opencode/src/marketplace/types.ts | 10 ++++- .../test/marketplace/marketplace.test.ts | 13 ++++++- 6 files changed, 78 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/marketplace.ts b/packages/opencode/src/cli/cmd/marketplace.ts index 698b58589356..4c98b231df1a 100644 --- a/packages/opencode/src/cli/cmd/marketplace.ts +++ b/packages/opencode/src/cli/cmd/marketplace.ts @@ -56,7 +56,7 @@ const MarketplaceInstallCommand = effectCmd({ yargs .positional("source", { type: "string", - describe: "package source (github:user/repo, url, or local path)", + describe: "package source (github:user/repo, gitlab:user/repo, url, or local path)", }) .option("name", { type: "string", @@ -64,19 +64,15 @@ const MarketplaceInstallCommand = effectCmd({ describe: "package name (default: auto-detected)", }), handler: Effect.fn("Cli.marketplace.install")(function* (args) { - const raw = String(args.source ?? "") - if (!raw) return yield* fail("source is required (e.g. github:user/repo, url, or path)") - - const sourceStr = raw.startsWith("github:") || raw.startsWith("gh:") - ? raw - : raw.startsWith("http://") || raw.startsWith("https://") - ? raw - : raw + const sourceStr = String(args.source ?? "") + if (!sourceStr) return yield* fail("source is required (e.g. github:user/repo, url, or path)") const pkgName = String(args.name ?? sourceStr.split("/").pop() ?? sourceStr.split(":").pop() ?? "package") const svc = yield* Marketplace.Service - yield* svc.install(pkgName, sourceStr) + yield* svc.install(pkgName, sourceStr).pipe( + Effect.catch((e) => fail(e.message)), + ) process.stdout.write(`Installed "${pkgName}" from ${sourceStr}\n`) }), }) @@ -151,6 +147,8 @@ function sourceLabel(source: { type: string; repo?: string; url?: string; path?: switch (source.type) { case "github": return `github:${source.repo}` + case "gitlab": + return `gitlab:${source.repo}` case "url": return source.url ?? "url" case "local": diff --git a/packages/opencode/src/marketplace/index.ts b/packages/opencode/src/marketplace/index.ts index 4e1e816a6467..f0181c207d1c 100644 --- a/packages/opencode/src/marketplace/index.ts +++ b/packages/opencode/src/marketplace/index.ts @@ -10,7 +10,7 @@ import type { PackageEntry } from "./types" export interface Interface { readonly search: (query: string) => Effect.Effect - readonly install: (pkgName: string, source: string) => Effect.Effect + readonly install: (pkgName: string, source: string) => Effect.Effect readonly uninstall: (name: string) => Effect.Effect readonly list: () => Effect.Effect readonly info: (name: string) => Effect.Effect @@ -68,17 +68,24 @@ export const layer = Layer.effect( const installPkg: Interface["install"] = (pkgName: string, sourceStr: string) => Effect.gen(function* () { const fetched = yield* sourceSvc.fetch(pkgName, sourceStr) - yield* installSvc.install(pkgName, fetched.dir) + if (!fetched.dir || !fetched.sourceUrl) { + return yield* Effect.fail(new Error(`Failed to fetch "${pkgName}" from "${sourceStr}"`)) + } + const result = yield* installSvc.install(pkgName, fetched.dir) const existing = yield* readStore() existing[pkgName] = new InstalledPkg({ name: pkgName, source: sourceStr, sourceUrl: fetched.sourceUrl, + assets: result.assets, installedAt: Date.now(), - installDir: fetched.dir, }) yield* writeStore(existing) + + yield* Effect.promise(() => + import("fs/promises").then((f) => f.rm(fetched.dir, { recursive: true, force: true })), + ).pipe(Effect.ignore) }) const uninstallPkg: Interface["uninstall"] = (name: string) => @@ -87,8 +94,8 @@ export const layer = Layer.effect( const entry = store[name] if (!entry) return - const assets = yield* installSvc.discover(entry.installDir) - yield* installSvc.uninstall(name, entry.installDir, assets) + const assets = entry.assets ?? new Install.Assets({ skills: [], agents: [], plugins: [] }) + yield* installSvc.uninstall(name, assets) delete store[name] yield* writeStore(store) diff --git a/packages/opencode/src/marketplace/install.ts b/packages/opencode/src/marketplace/install.ts index 9dcf828fc421..1547d998ecfc 100644 --- a/packages/opencode/src/marketplace/install.ts +++ b/packages/opencode/src/marketplace/install.ts @@ -18,7 +18,7 @@ export interface InstallResult { export interface Interface { readonly discover: (dir: string) => Effect.Effect readonly install: (pkgName: string, installDir: string) => Effect.Effect - readonly uninstall: (pkgName: string, installDir: string, assets: Assets) => Effect.Effect + readonly uninstall: (pkgName: string, assets: Assets) => Effect.Effect } export class Service extends Context.Service()("@opencode/MarketplaceInstall") {} @@ -90,7 +90,7 @@ export const layer = Layer.effect( return { assets, targetDir: installDir } }) - const doUninstall: Interface["uninstall"] = (pkgName: string, _installDir: string, assets: Assets) => + const doUninstall: Interface["uninstall"] = (pkgName: string, assets: Assets) => Effect.gen(function* () { if (assets.skills.length > 0) { yield* rmDir(path.join(global.config, "skills", pkgName)).pipe(Effect.ignore) diff --git a/packages/opencode/src/marketplace/source.ts b/packages/opencode/src/marketplace/source.ts index ec6956650c72..07740d9c6700 100644 --- a/packages/opencode/src/marketplace/source.ts +++ b/packages/opencode/src/marketplace/source.ts @@ -41,7 +41,37 @@ const cloneGitHub = ( ), ) if (result.code === 0) return { dir: dest, sourceUrl: `https://github.com/${repo}` } - return yield* Effect.die(new Error(`Failed to clone ${url}`)) + return yield* Effect.fail(new Error(`Failed to clone ${url}`)) + }).pipe(Effect.catch(() => Effect.succeed({ dir: "", sourceUrl: "" } as FetchResult))) + +const cloneGitLab = ( + pkgName: string, + source: string, + cacheRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const repo = source.replace(/^(gitlab:|gl:)/, "") + const dest = path.join(cacheRoot, pkgName) + + const valid = yield* Effect.promise(async (): Promise => { + try { + const entries = await import("fs/promises").then((f) => f.readdir(dest)) + return entries.length > 0 && entries.some((e) => e !== ".git") + } catch { + return false + } + }) + if (valid) return { dir: dest, sourceUrl: `https://gitlab.com/${repo}` } + yield* Effect.promise(() => import("fs/promises").then((f) => f.rm(dest, { recursive: true, force: true }))) + + const url = `https://gitlab.com/${repo}.git` + const result = yield* Effect.promise(() => + import("@/util/process").then(({ Process }) => + Process.run(["git", "clone", "--depth", "1", url, dest], { nothrow: true }), + ), + ) + if (result.code === 0) return { dir: dest, sourceUrl: `https://gitlab.com/${repo}` } + return yield* Effect.fail(new Error(`Failed to clone ${url}`)) }).pipe(Effect.catch(() => Effect.succeed({ dir: "", sourceUrl: "" } as FetchResult))) const downloadUrl = ( @@ -66,7 +96,7 @@ const downloadUrl = ( const buf = yield* Effect.promise((): Promise => globalThis.fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null)).catch(() => null), ) - if (!buf) return yield* Effect.die(new Error(`Failed to download ${url}`)) + if (!buf) return yield* Effect.fail(new Error(`Failed to download ${url}`)) const tmp = `${dest}.tmp` yield* Effect.promise(() => import("fs/promises").then((f) => f.writeFile(tmp, Buffer.from(buf)))) @@ -119,6 +149,9 @@ export const layer = Layer.effect( if (source.startsWith("github:") || source.startsWith("gh:")) { return yield* cloneGitHub(pkgName, source, cacheRoot) } + if (source.startsWith("gitlab:") || source.startsWith("gl:")) { + return yield* cloneGitLab(pkgName, source, cacheRoot) + } if (source.startsWith("http://") || source.startsWith("https://")) { return yield* downloadUrl(pkgName, source, cacheRoot) } diff --git a/packages/opencode/src/marketplace/types.ts b/packages/opencode/src/marketplace/types.ts index 69c51f13a472..27c99f786f07 100644 --- a/packages/opencode/src/marketplace/types.ts +++ b/packages/opencode/src/marketplace/types.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Assets } from "./install" export class GitHubSource extends Schema.Class("Marketplace.GitHubSource")({ type: Schema.Literal("github"), @@ -7,6 +8,13 @@ export class GitHubSource extends Schema.Class("Marketplace.GitHub path: Schema.optional(Schema.String), }) {} +export class GitLabSource extends Schema.Class("Marketplace.GitLabSource")({ + type: Schema.Literal("gitlab"), + repo: Schema.String, + ref: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), +}) {} + export class UrlSource extends Schema.Class("Marketplace.UrlSource")({ type: Schema.Literal("url"), url: Schema.String, @@ -35,8 +43,8 @@ export class InstalledPkg extends Schema.Class("Marketplace.Instal version: Schema.optional(Schema.String), source: Schema.Unknown, sourceUrl: Schema.String, + assets: Schema.optional(Assets), installedAt: Schema.Number, - installDir: Schema.String, }) {} export type InstalledStore = Record> diff --git a/packages/opencode/test/marketplace/marketplace.test.ts b/packages/opencode/test/marketplace/marketplace.test.ts index 8d5ad7fc6d02..ac3856079bc0 100644 --- a/packages/opencode/test/marketplace/marketplace.test.ts +++ b/packages/opencode/test/marketplace/marketplace.test.ts @@ -275,7 +275,7 @@ describe("Install", () => { Effect.gen(function* () { const s = yield* Install.Service const r = yield* s.install("test-pkg", src) - yield* s.uninstall("test-pkg", src, r.assets) + yield* s.uninstall("test-pkg", r.assets) }).pipe(Effect.provide( Install.layer.pipe( Layer.provide(fsLayer), @@ -337,6 +337,17 @@ describe("Source", () => { ), ) + it.live("routes gitlab: prefix to cloneGitLab", () => + Effect.scoped( + Effect.gen(function* () { + const svc = yield* Source.Service + const result = yield* svc.fetch("test-gitlab-pkg", "gitlab:user/test-gitlab-pkg") + // GitLab clone will fail (no network), but reaches cloneGitLab not copyLocal + expect(result.dir).toBe("") + }), + ), + ) + it.live("replaces stale empty cache directory", () => Effect.scoped( Effect.gen(function* () { From 43d32aaa0ad62a3de8b4c28b2489952c14d5d715 Mon Sep 17 00:00:00 2001 From: homigrotas Date: Wed, 24 Jun 2026 21:24:38 +0300 Subject: [PATCH 7/7] removed plugins and search command --- packages/opencode/src/cli/cmd/marketplace.ts | 30 ------ packages/opencode/src/marketplace/index.ts | 17 +--- packages/opencode/src/marketplace/install.ts | 7 +- .../test/marketplace/marketplace.test.ts | 95 ++----------------- 4 files changed, 13 insertions(+), 136 deletions(-) diff --git a/packages/opencode/src/cli/cmd/marketplace.ts b/packages/opencode/src/cli/cmd/marketplace.ts index 4c98b231df1a..a761466003bd 100644 --- a/packages/opencode/src/cli/cmd/marketplace.ts +++ b/packages/opencode/src/cli/cmd/marketplace.ts @@ -2,7 +2,6 @@ import { Effect } from "effect" import { cmd } from "./cmd" import { effectCmd, fail } from "../effect-cmd" import { Marketplace } from "@/marketplace" -import { UI } from "../ui" export const MarketplaceCommand = cmd({ command: "marketplace", @@ -10,7 +9,6 @@ export const MarketplaceCommand = cmd({ describe: "discover, install, and manage opencode packages", builder: (yargs) => yargs - .command(MarketplaceSearchCommand) .command(MarketplaceInstallCommand) .command(MarketplaceUninstallCommand) .command(MarketplaceListCommand) @@ -19,34 +17,6 @@ export const MarketplaceCommand = cmd({ async handler() {}, }) -const MarketplaceSearchCommand = effectCmd({ - command: "search [query]", - describe: "search available packages", - instance: false, - builder: (yargs) => - yargs.positional("query", { - type: "string", - describe: "search query", - }), - handler: Effect.fn("Cli.marketplace.search")(function* (args) { - const svc = yield* Marketplace.Service - const query = String(args.query ?? "") - const results = yield* svc.search(query) - if (results.length === 0) { - process.stdout.write("No packages found.\n") - return - } - for (const pkg of results) { - const src = pkg.source as { type: string; repo?: string; url?: string; path?: string } - process.stdout.write(`${pkg.name}\n`) - if (pkg.description) process.stdout.write(` ${pkg.description}\n`) - process.stdout.write(` source: ${sourceLabel(src)}\n`) - if (pkg.version) process.stdout.write(` version: ${pkg.version}\n`) - process.stdout.write("\n") - } - }), -}) - const MarketplaceInstallCommand = effectCmd({ command: "install ", aliases: ["i", "add"], diff --git a/packages/opencode/src/marketplace/index.ts b/packages/opencode/src/marketplace/index.ts index f0181c207d1c..e547b5095de6 100644 --- a/packages/opencode/src/marketplace/index.ts +++ b/packages/opencode/src/marketplace/index.ts @@ -6,10 +6,8 @@ import * as Registry from "./registry" import * as Source from "./source" import * as Install from "./install" import { InstalledPkg, MARKETPLACE_META_FILE } from "./types" -import type { PackageEntry } from "./types" export interface Interface { - readonly search: (query: string) => Effect.Effect readonly install: (pkgName: string, source: string) => Effect.Effect readonly uninstall: (name: string) => Effect.Effect readonly list: () => Effect.Effect @@ -53,18 +51,6 @@ export const layer = Layer.effect( const writeStore = (store: Record): Effect.Effect => fs.writeWithDirs(metaFile, JSON.stringify(store, null, 2)).pipe(Effect.catch(() => Effect.void)) - const search: Interface["search"] = (query: string) => - Effect.gen(function* () { - const all = yield* registrySvc.all() - if (!query) return all - const lower = query.toLowerCase() - return all.filter( - (p) => - p.name.toLowerCase().includes(lower) || - (p.description ?? "").toLowerCase().includes(lower), - ) - }) - const installPkg: Interface["install"] = (pkgName: string, sourceStr: string) => Effect.gen(function* () { const fetched = yield* sourceSvc.fetch(pkgName, sourceStr) @@ -94,7 +80,7 @@ export const layer = Layer.effect( const entry = store[name] if (!entry) return - const assets = entry.assets ?? new Install.Assets({ skills: [], agents: [], plugins: [] }) + const assets = entry.assets ?? new Install.Assets({ skills: [], agents: []}) yield* installSvc.uninstall(name, assets) delete store[name] @@ -114,7 +100,6 @@ export const layer = Layer.effect( }) return Service.of({ - search, install: installPkg, uninstall: uninstallPkg, list, diff --git a/packages/opencode/src/marketplace/install.ts b/packages/opencode/src/marketplace/install.ts index 1547d998ecfc..666556388136 100644 --- a/packages/opencode/src/marketplace/install.ts +++ b/packages/opencode/src/marketplace/install.ts @@ -7,7 +7,6 @@ import { Glob } from "@opencode-ai/core/util/glob" export class Assets extends Schema.Class("Marketplace.Assets")({ skills: Schema.Array(Schema.String), agents: Schema.Array(Schema.String), - plugins: Schema.Array(Schema.String), }) {} export interface InstallResult { @@ -56,19 +55,17 @@ export const layer = Layer.effect( const discover: Interface["discover"] = (dir: string) => Effect.gen(function* () { - const [allSkills, allAgents, allPlugins] = yield* Effect.all( + const [allSkills, allAgents] = yield* Effect.all( [ scanFiles(dir, "**/SKILL.md"), scanFiles(dir, "{agents,agent}/**/*.md"), - readPluginDirs(dir), ], { concurrency: 3 }, ) return new Assets({ skills: allSkills, - agents: allAgents, - plugins: allPlugins, + agents: allAgents }) }) diff --git a/packages/opencode/test/marketplace/marketplace.test.ts b/packages/opencode/test/marketplace/marketplace.test.ts index ac3856079bc0..e8091dc04229 100644 --- a/packages/opencode/test/marketplace/marketplace.test.ts +++ b/packages/opencode/test/marketplace/marketplace.test.ts @@ -163,21 +163,6 @@ describe("Install", () => { ), ) - it.live("discovers plugins via package.json exports", () => - Effect.scoped( - Effect.gen(function* () { - const dir = yield* tmpdirEffect() - yield* Effect.promise(() => - writeFile(dir, "package.json", JSON.stringify({ exports: { "./server": "./server.js" } })), - ) - - const svc = yield* Install.Service - const assets = yield* svc.discover(dir) - expect(assets.plugins).toHaveLength(1) - }), - ), - ) - it.live("returns empty assets for empty directory", () => Effect.scoped( Effect.gen(function* () { @@ -186,7 +171,6 @@ describe("Install", () => { const assets = yield* svc.discover(dir) expect(assets.skills).toEqual([]) expect(assets.agents).toEqual([]) - expect(assets.plugins).toEqual([]) }), ), ) @@ -372,65 +356,6 @@ describe("Source", () => { }) describe("Marketplace", () => { - describe("search", () => { - const mockRegistry = Layer.succeed(Registry.Service, Registry.Service.of({ - all: (query?: string) => { - const entries: PackageEntry[] = [ - { name: "test-pkg", description: "A test package", version: "1.0.0", source: { type: "github" as const, repo: "user/test-pkg" } }, - { name: "other-pkg", description: "Another package", source: { type: "url" as const, url: "https://example.com/pkg" } }, - ] - if (!query) return Effect.succeed(entries) - return Effect.succeed(entries.filter((p) => p.name.includes(query) || p.description?.includes(query))) - }, - fetchIndex: () => Effect.succeed([]), - scanLocal: () => Effect.succeed([]), - })) - - const it = testEffect( - Marketplace.layer.pipe( - Layer.provide(mockRegistry), - Layer.provide(Source.layer), - Layer.provide(Install.layer), - Layer.provide(fsLayer), - Layer.provide(Global.defaultLayer), - ), - ) - - it.effect("returns all packages without query", () => - Effect.gen(function* () { - const svc = yield* Marketplace.Service - const result = yield* svc.search("") - expect(result).toHaveLength(2) - }), - ) - - it.effect("filters by name", () => - Effect.gen(function* () { - const svc = yield* Marketplace.Service - const result = yield* svc.search("test") - expect(result).toHaveLength(1) - expect(result[0].name).toBe("test-pkg") - }), - ) - - it.effect("filters by description", () => - Effect.gen(function* () { - const svc = yield* Marketplace.Service - const result = yield* svc.search("another") - expect(result).toHaveLength(1) - expect(result[0].name).toBe("other-pkg") - }), - ) - - it.effect("returns empty for no match", () => - Effect.gen(function* () { - const svc = yield* Marketplace.Service - const result = yield* svc.search("nonexistent") - expect(result).toEqual([]) - }), - ) - }) - describe("install, list, info, uninstall", () => { test("install records the package", async () => { const stateDir = await (async () => { @@ -451,8 +376,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -500,8 +425,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -551,8 +476,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -598,8 +523,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [] }), targetDir: installDir }), uninstall: () => Effect.void, })) @@ -645,8 +570,8 @@ describe("Marketplace", () => { })) const mockInstall = Layer.succeed(Install.Service, Install.Service.of({ - discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: [], plugins: [] })), - install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [], plugins: [] }), targetDir: installDir }), + discover: () => Effect.succeed(new Install.Assets({ skills: [], agents: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), targetDir: installDir }), uninstall: () => Effect.void, }))