Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions packages/opencode/src/cli/cmd/marketplace.ts
Original file line number Diff line number Diff line change
@@ -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 <source>",
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 <name>",
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 <name>",
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"
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -88,6 +89,7 @@ export const AppLayer = Layer.mergeAll(
LSP.defaultLayer,
MCP.defaultLayer,
McpAuth.defaultLayer,
Marketplace.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -100,6 +101,7 @@ const cli = yargs(args)
.command(PrCommand)
.command(SessionCommand)
.command(PluginCommand)
.command(MarketplaceCommand)
.command(DbCommand)
.fail((msg, err) => {
if (
Expand Down
119 changes: 119 additions & 0 deletions packages/opencode/src/marketplace/index.ts
Original file line number Diff line number Diff line change
@@ -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<void, Error>
readonly uninstall: (name: string) => Effect.Effect<void>
readonly list: () => Effect.Effect<readonly InstalledPkg[]>
readonly info: (name: string) => Effect.Effect<InstalledPkg | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@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<Record<string, InstalledPkg>> =>
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<string, unknown>
const result: Record<string, InstalledPkg> = {}
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<string, InstalledPkg>): Effect.Effect<void> =>
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 "."
Loading
Loading