diff --git a/packages/opencode/src/cli/cmd/marketplace.ts b/packages/opencode/src/cli/cmd/marketplace.ts new file mode 100644 index 000000000000..a761466003bd --- /dev/null +++ b/packages/opencode/src/cli/cmd/marketplace.ts @@ -0,0 +1,129 @@ +import { Effect } from "effect" +import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" +import { Marketplace } from "@/marketplace" + +export const MarketplaceCommand = cmd({ + command: "marketplace", + aliases: ["market", "registry"], + describe: "discover, install, and manage opencode packages", + builder: (yargs) => + yargs + .command(MarketplaceInstallCommand) + .command(MarketplaceUninstallCommand) + .command(MarketplaceListCommand) + .command(MarketplaceInfoCommand) + .demandCommand(), + async handler() {}, +}) + +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, gitlab: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 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).pipe( + Effect.catch((e) => fail(e.message)), + ) + 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 "gitlab": + return `gitlab:${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..e547b5095de6 --- /dev/null +++ b/packages/opencode/src/marketplace/index.ts @@ -0,0 +1,119 @@ +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" + +export interface Interface { + 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 installPkg: Interface["install"] = (pkgName: string, sourceStr: string) => + Effect.gen(function* () { + const fetched = yield* sourceSvc.fetch(pkgName, sourceStr) + 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(), + }) + 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) => + Effect.gen(function* () { + const store = yield* readStore() + const entry = store[name] + if (!entry) return + + const assets = entry.assets ?? new Install.Assets({ skills: [], agents: []}) + yield* installSvc.uninstall(name, 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({ + 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..666556388136 --- /dev/null +++ b/packages/opencode/src/marketplace/install.ts @@ -0,0 +1,129 @@ +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), +}) {} + +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, 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] = yield* Effect.all( + [ + scanFiles(dir, "**/SKILL.md"), + scanFiles(dir, "{agents,agent}/**/*.md"), + ], + { concurrency: 3 }, + ) + + return new Assets({ + skills: allSkills, + agents: allAgents + }) + }) + + const doInstall: Interface["install"] = (pkgName: string, installDir: string) => + Effect.gen(function* () { + const assets = yield* discover(installDir) + + for (const skillPath of assets.skills) { + 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) { + yield* copyFile(agentPath, path.join(global.config, "agents", path.basename(agentPath)), fs).pipe(Effect.ignore) + } + return { assets, targetDir: installDir } + }) + + 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) + } + for (const ap of assets.agents) { + yield* rmFile(path.join(global.config, "agents", path.basename(ap))).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..07740d9c6700 --- /dev/null +++ b/packages/opencode/src/marketplace/source.ts @@ -0,0 +1,167 @@ +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 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(() => + 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.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 = ( + pkgName: string, + url: string, + cacheRoot: string, +): Effect.Effect => + Effect.gen(function* () { + 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 + } 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), + ) + 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)))) + 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 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) + 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("gitlab:") || source.startsWith("gl:")) { + return yield* cloneGitLab(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..27c99f786f07 --- /dev/null +++ b/packages/opencode/src/marketplace/types.ts @@ -0,0 +1,53 @@ +import { Schema } from "effect" +import { Assets } from "./install" + +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 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, +}) {} + +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, + assets: Schema.optional(Assets), + installedAt: Schema.Number, +}) {} + +export type InstalledStore = Record> + +export const MARKETPLACE_META_FILE = "marketplace.json" +export const MARKETPLACE_CACHE_DIR = "marketplace" diff --git a/packages/opencode/test/marketplace/marketplace.test.ts b/packages/opencode/test/marketplace/marketplace.test.ts new file mode 100644 index 000000000000..e8091dc04229 --- /dev/null +++ b/packages/opencode/test/marketplace/marketplace.test.ts @@ -0,0 +1,603 @@ +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", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* tmpdirEffect() + yield* Effect.promise(() => writeFile(dir, "agents/my-agent.md", "# Agent")) + + const svc = yield* Install.Service + const assets = yield* svc.discover(dir) + expect(assets.agents).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([]) + }), + ), + ) + }) + + 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", 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("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* () { + 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("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: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), 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: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), 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: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), 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: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: [] }), 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: []})), + install: (_name, installDir) => Effect.succeed({ assets: new Install.Assets({ skills: [], agents: []}), 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), + ) + } + }) + + }) +})