diff --git a/bun.lock b/bun.lock
index 9aba2f10f41..ff9d3eed2cd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
"workspaces": {
"": {
"name": "opencode",
diff --git a/packages/console/app/vite.config.ts b/packages/console/app/vite.config.ts
index 3b013e99011..0d3465a590f 100644
--- a/packages/console/app/vite.config.ts
+++ b/packages/console/app/vite.config.ts
@@ -11,7 +11,7 @@ export default defineConfig({
cloudflare: {
nodeCompat: true,
},
- }),
+ }) as PluginOption,
],
server: {
allowedHosts: true,
diff --git a/packages/enterprise/vite.config.ts b/packages/enterprise/vite.config.ts
index 11ca1729dfe..307f6903ce5 100644
--- a/packages/enterprise/vite.config.ts
+++ b/packages/enterprise/vite.config.ts
@@ -24,7 +24,7 @@ export default defineConfig({
nitro({
...nitroConfig,
baseURL: process.env.OPENCODE_BASE_URL,
- }),
+ }) as PluginOption,
],
server: {
host: "0.0.0.0",
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index aa62c6c58ef..85f667a8cd5 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -12,6 +12,7 @@ import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
+import { DialogMarketplace } from "@tui/component/dialog-marketplace"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
@@ -379,6 +380,14 @@ function App() {
dialog.replace(() => )
},
},
+ {
+ title: "Browse marketplace",
+ value: "marketplace.browse",
+ category: "Agent",
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
{
title: "Agent cycle",
value: "agent.cycle",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-marketplace.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-marketplace.tsx
new file mode 100644
index 00000000000..c4b99a600bf
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-marketplace.tsx
@@ -0,0 +1,345 @@
+import { createMemo, createSignal, onMount, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { useDialog } from "@tui/ui/dialog"
+import { useTheme } from "@tui/context/theme"
+import { Keybind } from "@/util/keybind"
+import { TextAttributes } from "@opentui/core"
+import { useToast } from "@tui/ui/toast"
+import type { Marketplace } from "@/marketplace"
+
+// Types matching server API response
+interface MarketplaceAgent {
+ source: {
+ repo: string
+ ref?: string
+ path?: string
+ private?: boolean
+ enabled?: boolean
+ name?: string
+ }
+ agent: {
+ path: string
+ name: string
+ description?: string
+ mode?: "subagent" | "primary" | "all"
+ color?: string
+ tags?: string[]
+ version?: string
+ author?: string
+ }
+ installed: boolean
+ installedPath?: string
+}
+
+function InstallStatus(props: { installed: boolean; installing?: boolean }) {
+ const { theme } = useTheme()
+ if (props.installing) {
+ return ⋯ Installing
+ }
+ if (props.installed) {
+ return ✓ Installed
+ }
+ return ○ Not installed
+}
+
+export function DialogMarketplace() {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const toast = useToast()
+
+ const [store, setStore] = createStore({
+ loading: true,
+ agents: [] as MarketplaceAgent[],
+ error: null as string | null,
+ installing: null as string | null,
+ })
+
+ onMount(async () => {
+ await refreshAgents()
+ })
+
+ async function refreshAgents(forceRefresh = false) {
+ setStore("loading", true)
+ setStore("error", null)
+ try {
+ const url = new URL("/marketplace/agents", "http://localhost:4096")
+ if (forceRefresh) {
+ url.searchParams.set("refresh", "true")
+ }
+ const response = await fetch(url.toString())
+ if (!response.ok) {
+ throw new Error(`Failed to fetch agents: ${response.statusText}`)
+ }
+ const agents = (await response.json()) as MarketplaceAgent[]
+ setStore("agents", agents)
+ } catch (error) {
+ setStore("error", error instanceof Error ? error.message : "Unknown error")
+ } finally {
+ setStore("loading", false)
+ }
+ }
+
+ async function installAgent(agent: MarketplaceAgent, scope: "global" | "project") {
+ const key = `${agent.source.repo}:${agent.agent.path}`
+ setStore("installing", key)
+ try {
+ const response = await fetch("/marketplace/agents/install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ source: agent.source,
+ agentPath: agent.agent.path,
+ scope,
+ }),
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || `Failed to install: ${response.statusText}`)
+ }
+
+ toast.show({
+ title: "Agent installed",
+ message: `${agent.agent.name} installed to ${scope} scope`,
+ variant: "success",
+ })
+
+ // Refresh the list to update installed status
+ await refreshAgents()
+ } catch (error) {
+ toast.show({
+ title: "Installation failed",
+ message: error instanceof Error ? error.message : "Unknown error",
+ variant: "error",
+ })
+ } finally {
+ setStore("installing", null)
+ }
+ }
+
+ const options = createMemo(() => {
+ if (store.loading) {
+ return [
+ {
+ title: "Loading...",
+ value: null,
+ description: "Fetching agents from marketplace sources",
+ disabled: true,
+ },
+ ]
+ }
+
+ if (store.error) {
+ return [
+ {
+ title: "Error",
+ value: null,
+ description: store.error,
+ disabled: true,
+ },
+ ]
+ }
+
+ if (store.agents.length === 0) {
+ return [
+ {
+ title: "No agents found",
+ value: null,
+ description: "Configure marketplace sources in opencode.jsonc",
+ disabled: true,
+ },
+ ]
+ }
+
+ return store.agents.map((agent) => {
+ const key = `${agent.source.repo}:${agent.agent.path}`
+ const sourceName = agent.source.name || agent.source.repo
+ return {
+ title: agent.agent.name,
+ value: agent,
+ description: agent.agent.description || "No description",
+ category: sourceName,
+ footer: ,
+ }
+ })
+ })
+
+ const keybinds = createMemo(() => [
+ {
+ keybind: Keybind.parse("ctrl+r")[0],
+ title: "refresh",
+ onTrigger: async () => {
+ await refreshAgents(true)
+ },
+ },
+ {
+ keybind: Keybind.parse("i")[0],
+ title: "install (project)",
+ onTrigger: async (option: DialogSelectOption) => {
+ if (!option.value || option.value.installed) return
+ await installAgent(option.value, "project")
+ },
+ },
+ {
+ keybind: Keybind.parse("shift+i")[0],
+ title: "install (global)",
+ onTrigger: async (option: DialogSelectOption) => {
+ if (!option.value || option.value.installed) return
+ await installAgent(option.value, "global")
+ },
+ },
+ {
+ keybind: Keybind.parse("return")[0],
+ title: "details",
+ onTrigger: (option: DialogSelectOption) => {
+ if (!option.value) return
+ dialog.replace(() => dialog.replace(() => )} />)
+ },
+ },
+ ])
+
+ return (
+ {
+ if (!option.value) return
+ dialog.replace(() => dialog.replace(() => )} />)
+ }}
+ />
+ )
+}
+
+function DialogMarketplaceDetail(props: { agent: MarketplaceAgent; onBack: () => void }) {
+ const { theme } = useTheme()
+ const dialog = useDialog()
+ const toast = useToast()
+ const [installing, setInstalling] = createSignal(false)
+
+ async function handleInstall(scope: "global" | "project") {
+ setInstalling(true)
+ try {
+ const response = await fetch("/marketplace/agents/install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ source: props.agent.source,
+ agentPath: props.agent.agent.path,
+ scope,
+ }),
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || `Failed to install: ${response.statusText}`)
+ }
+
+ toast.show({
+ title: "Agent installed",
+ message: `${props.agent.agent.name} installed to ${scope} scope`,
+ variant: "success",
+ })
+ props.onBack()
+ } catch (error) {
+ toast.show({
+ title: "Installation failed",
+ message: error instanceof Error ? error.message : "Unknown error",
+ variant: "error",
+ })
+ } finally {
+ setInstalling(false)
+ }
+ }
+
+ const options = createMemo(() => {
+ const opts: DialogSelectOption[] = []
+
+ if (!props.agent.installed) {
+ opts.push({
+ title: "Install to project",
+ value: "install-project",
+ description: "Install agent to .opencode/agent/ in current project",
+ })
+ opts.push({
+ title: "Install globally",
+ value: "install-global",
+ description: "Install agent to ~/.config/opencode/agent/",
+ })
+ } else {
+ opts.push({
+ title: "Already installed",
+ value: "installed",
+ description: props.agent.installedPath || "Agent is already installed",
+ disabled: true,
+ })
+ }
+
+ opts.push({
+ title: "Back to list",
+ value: "back",
+ description: "Return to marketplace list",
+ })
+
+ return opts
+ })
+
+ const header = createMemo(() => {
+ const agent = props.agent.agent
+ const source = props.agent.source
+ return (
+
+ {agent.name}
+ {agent.description || "No description"}
+
+ Source:
+ {source.name || source.repo}
+
+
+
+ Author:
+ {agent.author}
+
+
+ 0}>
+
+ Tags:
+ {agent.tags!.join(", ")}
+
+
+
+
+ Mode:
+ {agent.mode}
+
+
+
+ )
+ })
+
+ return (
+
+ {header()}
+ {
+ if (installing()) return
+ switch (option.value) {
+ case "install-project":
+ await handleInstall("project")
+ break
+ case "install-global":
+ await handleInstall("global")
+ break
+ case "back":
+ props.onBack()
+ break
+ }
+ }}
+ />
+
+ )
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index ead3a0149b4..9e0190d9584 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -19,6 +19,7 @@ import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs"
+import { MarketplaceSchema } from "../marketplace/schema"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -1048,6 +1049,9 @@ export namespace Config {
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
+ marketplace: MarketplaceSchema.Config.optional().describe(
+ "Marketplace configuration for discovering and installing agents from GitHub repositories",
+ ),
})
.strict()
.meta({
diff --git a/packages/opencode/src/marketplace/cache.ts b/packages/opencode/src/marketplace/cache.ts
new file mode 100644
index 00000000000..a20ecc04bff
--- /dev/null
+++ b/packages/opencode/src/marketplace/cache.ts
@@ -0,0 +1,237 @@
+import path from "path"
+import fs from "fs/promises"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { MarketplaceSchema } from "./schema"
+import { MarketplaceGitHub } from "./github"
+import { MarketplaceDiscovery } from "./discovery"
+
+export namespace MarketplaceCache {
+ const log = Log.create({ service: "marketplace.cache" })
+
+ // Cache directory
+ const CACHE_DIR = path.join(Global.Path.cache, "marketplace")
+
+ // Ensure cache directory exists
+ async function ensureCacheDir(): Promise {
+ await fs.mkdir(CACHE_DIR, { recursive: true })
+ }
+
+ // Get cache file path for a source
+ function getCacheFilePath(source: MarketplaceSchema.Source): string {
+ // Create a safe filename from the repo
+ const safeRepo = source.repo.replace(/[/\\:*?"<>|]/g, "_")
+ const ref = source.ref || "default"
+ return path.join(CACHE_DIR, `${safeRepo}_${ref}.json`)
+ }
+
+ // Get content cache directory for a source
+ function getContentCacheDir(source: MarketplaceSchema.Source): string {
+ const safeRepo = source.repo.replace(/[/\\:*?"<>|]/g, "_")
+ return path.join(CACHE_DIR, "content", safeRepo)
+ }
+
+ // Get cached registry for a source
+ export async function getCachedRegistry(
+ source: MarketplaceSchema.Source,
+ cacheDuration: number = 3600000,
+ ): Promise {
+ const cacheFile = getCacheFilePath(source)
+ try {
+ const data = await Bun.file(cacheFile).json()
+ const parsed = MarketplaceSchema.CachedSource.parse(data)
+
+ // Check if cache is still valid
+ if (Date.now() - parsed.lastFetched < cacheDuration) {
+ log.debug("cache hit", { repo: source.repo })
+ return parsed
+ }
+
+ log.debug("cache expired", { repo: source.repo })
+ } catch {
+ log.debug("cache miss", { repo: source.repo })
+ }
+ return null
+ }
+
+ // Save cached source data
+ async function saveCachedSource(
+ source: MarketplaceSchema.Source,
+ cached: MarketplaceSchema.CachedSource,
+ ): Promise {
+ await ensureCacheDir()
+ const cacheFile = getCacheFilePath(source)
+ await Bun.write(cacheFile, JSON.stringify(cached, null, 2))
+ log.debug("cache saved", { repo: source.repo })
+ }
+
+ // Try to fetch registry.json from GitHub
+ async function fetchRegistryJson(
+ source: MarketplaceSchema.Source,
+ cached: MarketplaceSchema.CachedSource | null,
+ ): Promise<{ registry: MarketplaceSchema.RegistryIndex; etag?: string } | null> {
+ const registryPath = source.path ? `${source.path}/registry.json` : "registry.json"
+
+ try {
+ if (cached?.etag) {
+ // Try conditional request
+ const result = await MarketplaceGitHub.getContentIfModified(
+ source.repo,
+ registryPath,
+ source.ref,
+ cached.etag,
+ )
+
+ if (!result.modified) {
+ // Not modified, return cached registry
+ return { registry: cached.registry, etag: cached.etag }
+ }
+
+ // Modified, parse new registry
+ const registry = MarketplaceSchema.RegistryIndex.parse(JSON.parse(result.content))
+ return { registry, etag: result.etag }
+ } else {
+ // No cached data, fetch fresh
+ const { content, etag } = await MarketplaceGitHub.getRawContent(
+ source.repo,
+ registryPath,
+ source.ref,
+ )
+
+ const registry = MarketplaceSchema.RegistryIndex.parse(JSON.parse(content))
+ return { registry, etag }
+ }
+ } catch (error) {
+ // registry.json not found or invalid
+ log.debug("registry.json not found or invalid", { repo: source.repo, error })
+ return null
+ }
+ }
+
+ // Refresh registry from GitHub (with discovery fallback)
+ export async function refreshRegistry(
+ source: MarketplaceSchema.Source,
+ ): Promise {
+ log.info("refreshing registry", { repo: source.repo })
+
+ // Get cached data for conditional request
+ const cached = await getCachedRegistry(source, Infinity) // Get cached regardless of age
+
+ // First, try to fetch registry.json
+ const registryResult = await fetchRegistryJson(source, cached)
+
+ if (registryResult) {
+ // registry.json found
+ log.debug("using registry.json", { repo: source.repo })
+ const newCached: MarketplaceSchema.CachedSource = {
+ source,
+ registry: registryResult.registry,
+ lastFetched: Date.now(),
+ etag: registryResult.etag,
+ }
+ await saveCachedSource(source, newCached)
+ return newCached
+ }
+
+ // Fallback: use discovery to scan for agents (Claude Code compatibility)
+ log.info("registry.json not found, using auto-discovery", { repo: source.repo })
+
+ try {
+ const registry = await MarketplaceDiscovery.buildRegistry(source)
+
+ const newCached: MarketplaceSchema.CachedSource = {
+ source,
+ registry,
+ lastFetched: Date.now(),
+ // No etag for discovered registries
+ }
+ await saveCachedSource(source, newCached)
+ return newCached
+ } catch (error) {
+ // Discovery failed
+ log.error("discovery failed", { repo: source.repo, error })
+
+ // If we have cached data, return it
+ if (cached) {
+ log.warn("using cached data after discovery failure", { repo: source.repo })
+ return cached
+ }
+
+ throw error
+ }
+ }
+
+ // Get or refresh registry (main entry point)
+ export async function getRegistry(
+ source: MarketplaceSchema.Source,
+ options: { refresh?: boolean; cacheDuration?: number } = {},
+ ): Promise {
+ const { refresh = false, cacheDuration = 3600000 } = options
+
+ if (refresh) {
+ return refreshRegistry(source)
+ }
+
+ const cached = await getCachedRegistry(source, cacheDuration)
+ if (cached) {
+ return cached
+ }
+
+ return refreshRegistry(source)
+ }
+
+ // Cache agent content locally
+ export async function cacheAgentContent(
+ source: MarketplaceSchema.Source,
+ agentPath: string,
+ content: string,
+ ): Promise {
+ const contentDir = getContentCacheDir(source)
+ await fs.mkdir(contentDir, { recursive: true })
+
+ const localPath = path.join(contentDir, agentPath)
+ await fs.mkdir(path.dirname(localPath), { recursive: true })
+ await Bun.write(localPath, content)
+
+ return localPath
+ }
+
+ // Get cached agent content
+ export async function getCachedAgentContent(
+ source: MarketplaceSchema.Source,
+ agentPath: string,
+ ): Promise {
+ const contentDir = getContentCacheDir(source)
+ const localPath = path.join(contentDir, agentPath)
+
+ try {
+ return await Bun.file(localPath).text()
+ } catch {
+ return null
+ }
+ }
+
+ // Clear all cache
+ export async function clearAll(): Promise {
+ try {
+ await fs.rm(CACHE_DIR, { recursive: true, force: true })
+ log.info("cache cleared")
+ } catch {
+ // Cache dir might not exist
+ }
+ }
+
+ // Clear cache for a specific source
+ export async function clearSource(source: MarketplaceSchema.Source): Promise {
+ const cacheFile = getCacheFilePath(source)
+ const contentDir = getContentCacheDir(source)
+
+ try {
+ await fs.rm(cacheFile, { force: true })
+ await fs.rm(contentDir, { recursive: true, force: true })
+ log.info("source cache cleared", { repo: source.repo })
+ } catch {
+ // Files might not exist
+ }
+ }
+}
diff --git a/packages/opencode/src/marketplace/discovery.ts b/packages/opencode/src/marketplace/discovery.ts
new file mode 100644
index 00000000000..073e6e6a036
--- /dev/null
+++ b/packages/opencode/src/marketplace/discovery.ts
@@ -0,0 +1,250 @@
+import { Log } from "../util/log"
+import { MarketplaceSchema } from "./schema"
+import { MarketplaceGitHub } from "./github"
+import matter from "gray-matter"
+
+export namespace MarketplaceDiscovery {
+ const log = Log.create({ service: "marketplace.discovery" })
+
+ // Common directories to scan for agents (in priority order)
+ const AGENT_DIRECTORIES = [
+ // Our convention
+ "agents",
+ "agent",
+ // Claude Code conventions
+ ".claude/agents",
+ ".claude/skills",
+ // Root level (for simple repos)
+ "",
+ ]
+
+ // File patterns to look for
+ const AGENT_FILE_PATTERNS = [
+ /\.md$/i, // Any markdown file
+ ]
+
+ // Skill file pattern (Claude Code)
+ const SKILL_FILE_PATTERN = /SKILL\.md$/i
+
+ interface DiscoveredFile {
+ path: string
+ name: string
+ isSkill: boolean
+ }
+
+ // Parse frontmatter from markdown content
+ function parseFrontmatter(content: string): Record | null {
+ try {
+ const parsed = matter(content)
+ return parsed.data as Record
+ } catch {
+ return null
+ }
+ }
+
+ // Convert discovered file to AgentEntry
+ function fileToAgentEntry(
+ file: DiscoveredFile,
+ frontmatter: Record,
+ content: string,
+ ): MarketplaceSchema.AgentEntry {
+ // Extract name from frontmatter or filename
+ const name =
+ (frontmatter.name as string) ||
+ file.name.replace(/\.md$/i, "").replace(/^SKILL$/i, file.path.split("/").slice(-2)[0] || "skill")
+
+ // Extract description
+ const description =
+ (frontmatter.description as string) ||
+ (frontmatter.desc as string) ||
+ // Try to get first paragraph from content
+ extractFirstParagraph(content)
+
+ // Map mode - support both our format and Claude Code format
+ let mode: "subagent" | "primary" | "all" | undefined
+ if (frontmatter.mode) {
+ mode = frontmatter.mode as "subagent" | "primary" | "all"
+ } else if (file.isSkill) {
+ mode = "subagent" // Skills are typically subagents
+ }
+
+ // Extract tags
+ let tags: string[] | undefined
+ if (Array.isArray(frontmatter.tags)) {
+ tags = frontmatter.tags as string[]
+ } else if (typeof frontmatter.tags === "string") {
+ tags = (frontmatter.tags as string).split(",").map((t) => t.trim())
+ }
+ // Claude Code uses "triggers" for skills
+ if (Array.isArray(frontmatter.triggers)) {
+ tags = [...(tags || []), ...(frontmatter.triggers as string[])]
+ }
+
+ return {
+ path: file.path,
+ name,
+ description,
+ mode,
+ color: frontmatter.color as string | undefined,
+ tags,
+ version: frontmatter.version as string | undefined,
+ author: frontmatter.author as string | undefined,
+ }
+ }
+
+ // Extract first paragraph from markdown content (after frontmatter)
+ function extractFirstParagraph(content: string): string | undefined {
+ // Remove frontmatter
+ const withoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, "")
+ // Find first non-empty paragraph
+ const lines = withoutFrontmatter.split("\n")
+ const paragraphLines: string[] = []
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ // Skip headers and empty lines at start
+ if (paragraphLines.length === 0 && (trimmed === "" || trimmed.startsWith("#"))) {
+ continue
+ }
+ // Stop at empty line or header after we've started collecting
+ if (paragraphLines.length > 0 && (trimmed === "" || trimmed.startsWith("#"))) {
+ break
+ }
+ paragraphLines.push(trimmed)
+ }
+
+ const paragraph = paragraphLines.join(" ").trim()
+ // Limit length
+ if (paragraph.length > 200) {
+ return paragraph.slice(0, 197) + "..."
+ }
+ return paragraph || undefined
+ }
+
+ // Check if a file looks like an agent/skill file
+ function isAgentFile(filename: string): boolean {
+ return AGENT_FILE_PATTERNS.some((pattern) => pattern.test(filename))
+ }
+
+ // Recursively list all markdown files in a directory
+ async function listMarkdownFiles(
+ repo: string,
+ dirPath: string,
+ ref?: string,
+ maxDepth: number = 3,
+ currentDepth: number = 0,
+ ): Promise {
+ if (currentDepth >= maxDepth) {
+ return []
+ }
+
+ const files: DiscoveredFile[] = []
+
+ try {
+ const entries = await MarketplaceGitHub.listDirectory(repo, dirPath, ref)
+
+ for (const entry of entries) {
+ if (entry.type === "file" && isAgentFile(entry.name)) {
+ files.push({
+ path: entry.path,
+ name: entry.name,
+ isSkill: SKILL_FILE_PATTERN.test(entry.name),
+ })
+ } else if (entry.type === "dir") {
+ // Recurse into subdirectories
+ const subFiles = await listMarkdownFiles(repo, entry.path, ref, maxDepth, currentDepth + 1)
+ files.push(...subFiles)
+ }
+ }
+ } catch (error) {
+ // Directory doesn't exist or access denied - that's ok
+ log.debug("failed to list directory", { repo, dirPath, error })
+ }
+
+ return files
+ }
+
+ // Discover agents in a repository without registry.json
+ export async function discoverAgents(
+ source: MarketplaceSchema.Source,
+ ): Promise {
+ log.info("discovering agents", { repo: source.repo })
+
+ const agents: MarketplaceSchema.AgentEntry[] = []
+ const seenPaths = new Set()
+
+ // Determine base path
+ const basePath = source.path || ""
+
+ for (const dir of AGENT_DIRECTORIES) {
+ const searchPath = basePath ? `${basePath}/${dir}`.replace(/^\/+/, "") : dir
+
+ log.debug("scanning directory", { repo: source.repo, path: searchPath || "(root)" })
+
+ const files = await listMarkdownFiles(source.repo, searchPath, source.ref)
+
+ for (const file of files) {
+ // Skip if we've already seen this file
+ if (seenPaths.has(file.path)) {
+ continue
+ }
+ seenPaths.add(file.path)
+
+ try {
+ // Fetch file content
+ const { content } = await MarketplaceGitHub.getRawContent(source.repo, file.path, source.ref)
+
+ // Parse frontmatter
+ const frontmatter = parseFrontmatter(content)
+
+ // Skip files without frontmatter or that don't look like agents
+ if (!frontmatter) {
+ log.debug("skipping file without frontmatter", { path: file.path })
+ continue
+ }
+
+ // Check if it looks like an agent/skill file
+ // Must have at least a description, prompt content, or be a SKILL.md
+ const hasAgentIndicators =
+ file.isSkill ||
+ frontmatter.description ||
+ frontmatter.mode ||
+ frontmatter.model ||
+ frontmatter.prompt ||
+ frontmatter.tools ||
+ frontmatter.permission
+
+ if (!hasAgentIndicators) {
+ log.debug("skipping file without agent indicators", { path: file.path })
+ continue
+ }
+
+ const entry = fileToAgentEntry(file, frontmatter, content)
+ agents.push(entry)
+
+ log.debug("discovered agent", { name: entry.name, path: file.path })
+ } catch (error) {
+ log.debug("failed to process file", { path: file.path, error })
+ }
+ }
+ }
+
+ log.info("discovery complete", { repo: source.repo, count: agents.length })
+ return agents
+ }
+
+ // Build a registry from discovered agents
+ export async function buildRegistry(
+ source: MarketplaceSchema.Source,
+ ): Promise {
+ const agents = await discoverAgents(source)
+
+ return {
+ version: "1",
+ name: source.name || source.repo.split("/").pop() || source.repo,
+ description: `Auto-discovered agents from ${source.repo}`,
+ types: ["agent"],
+ agents,
+ }
+ }
+}
diff --git a/packages/opencode/src/marketplace/github.ts b/packages/opencode/src/marketplace/github.ts
new file mode 100644
index 00000000000..261b46a32b4
--- /dev/null
+++ b/packages/opencode/src/marketplace/github.ts
@@ -0,0 +1,237 @@
+import { Auth } from "../auth"
+import { Log } from "../util/log"
+import { spawn } from "bun"
+
+export namespace MarketplaceGitHub {
+ const log = Log.create({ service: "marketplace.github" })
+
+ // GitHub API base URL
+ const GITHUB_API = "https://api.github.com"
+
+ // Try to get GitHub token from gh CLI
+ async function getGhCliToken(): Promise {
+ try {
+ const proc = spawn(["gh", "auth", "token"], {
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ const output = await new Response(proc.stdout).text()
+ const exitCode = await proc.exited
+ if (exitCode === 0 && output.trim()) {
+ return output.trim()
+ }
+ } catch {
+ // gh CLI not available or not authenticated
+ }
+ return undefined
+ }
+
+ // Try authentication sources in priority order
+ export async function getToken(): Promise {
+ // 1. Check for GITHUB_TOKEN environment variable
+ if (process.env.GITHUB_TOKEN) {
+ log.debug("using GITHUB_TOKEN from environment")
+ return process.env.GITHUB_TOKEN
+ }
+
+ // 2. Check for GH_TOKEN environment variable (gh CLI convention)
+ if (process.env.GH_TOKEN) {
+ log.debug("using GH_TOKEN from environment")
+ return process.env.GH_TOKEN
+ }
+
+ // 3. Check opencode auth store for github-copilot OAuth
+ try {
+ const auth = await Auth.get("github-copilot")
+ if (auth?.type === "oauth" && auth.access) {
+ log.debug("using github-copilot OAuth token from auth store")
+ return auth.access
+ }
+ } catch {
+ // Auth not available
+ }
+
+ // 4. Check for gh CLI token
+ const ghToken = await getGhCliToken()
+ if (ghToken) {
+ log.debug("using token from gh CLI")
+ return ghToken
+ }
+
+ // 5. Return undefined for public repo access only
+ log.debug("no GitHub token available, using unauthenticated access")
+ return undefined
+ }
+
+ // Check if we have any authentication available
+ export async function isAuthenticated(): Promise {
+ const token = await getToken()
+ return token !== undefined
+ }
+
+ // Fetch with authentication
+ export async function fetch(url: string, options?: RequestInit): Promise {
+ const token = await getToken()
+ const headers = new Headers(options?.headers)
+
+ if (token) {
+ headers.set("Authorization", `Bearer ${token}`)
+ }
+ headers.set("Accept", "application/vnd.github.v3+json")
+ headers.set("X-GitHub-Api-Version", "2022-11-28")
+
+ log.debug("fetching", { url, authenticated: !!token })
+
+ return globalThis.fetch(url, { ...options, headers })
+ }
+
+ // Parse a repo string into owner and repo
+ export function parseRepo(repo: string): { owner: string; repo: string } {
+ // Handle full URLs
+ if (repo.startsWith("https://github.com/")) {
+ repo = repo.replace("https://github.com/", "")
+ }
+ // Handle git URLs
+ if (repo.startsWith("git@github.com:")) {
+ repo = repo.replace("git@github.com:", "").replace(".git", "")
+ }
+ // Handle trailing .git
+ repo = repo.replace(/\.git$/, "")
+ // Handle trailing slashes
+ repo = repo.replace(/\/$/, "")
+
+ const parts = repo.split("/")
+ if (parts.length !== 2) {
+ throw new Error(`Invalid repository format: ${repo}. Expected 'owner/repo'`)
+ }
+
+ return { owner: parts[0], repo: parts[1] }
+ }
+
+ // Get the default branch for a repository
+ export async function getDefaultBranch(repoString: string): Promise {
+ const { owner, repo } = parseRepo(repoString)
+ const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`)
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`Repository not found: ${repoString}`)
+ }
+ if (response.status === 403) {
+ throw new Error(`Access denied to repository: ${repoString}. You may need to authenticate.`)
+ }
+ throw new Error(`Failed to get repository info: ${response.status} ${response.statusText}`)
+ }
+
+ const data = (await response.json()) as { default_branch: string }
+ return data.default_branch
+ }
+
+ // Get raw file content from a repository
+ export async function getRawContent(
+ repoString: string,
+ filePath: string,
+ ref?: string,
+ ): Promise<{ content: string; etag?: string }> {
+ const { owner, repo } = parseRepo(repoString)
+
+ // If no ref specified, get the default branch
+ const targetRef = ref || (await getDefaultBranch(repoString))
+
+ // Use raw.githubusercontent.com for raw content
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${targetRef}/${filePath}`
+
+ const response = await fetch(rawUrl)
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`File not found: ${filePath} in ${repoString}@${targetRef}`)
+ }
+ if (response.status === 403) {
+ throw new Error(`Access denied to ${repoString}. You may need to authenticate.`)
+ }
+ throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
+ }
+
+ const content = await response.text()
+ const etag = response.headers.get("ETag") ?? undefined
+
+ return { content, etag }
+ }
+
+ // Get file content with conditional request (using ETag)
+ export async function getContentIfModified(
+ repoString: string,
+ filePath: string,
+ ref?: string,
+ etag?: string,
+ ): Promise<{ content: string; etag?: string; modified: boolean } | { modified: false }> {
+ const { owner, repo } = parseRepo(repoString)
+
+ // If no ref specified, get the default branch
+ const targetRef = ref || (await getDefaultBranch(repoString))
+
+ // Use raw.githubusercontent.com for raw content
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${targetRef}/${filePath}`
+
+ const headers: Record = {}
+ if (etag) {
+ headers["If-None-Match"] = etag
+ }
+
+ const response = await fetch(rawUrl, { headers })
+
+ if (response.status === 304) {
+ return { modified: false }
+ }
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`File not found: ${filePath} in ${repoString}@${targetRef}`)
+ }
+ if (response.status === 403) {
+ throw new Error(`Access denied to ${repoString}. You may need to authenticate.`)
+ }
+ throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
+ }
+
+ const content = await response.text()
+ const newEtag = response.headers.get("ETag") ?? undefined
+
+ return { content, etag: newEtag, modified: true }
+ }
+
+ // List files in a directory (using GitHub API)
+ export async function listDirectory(
+ repoString: string,
+ dirPath: string,
+ ref?: string,
+ ): Promise<{ name: string; type: "file" | "dir"; path: string }[]> {
+ const { owner, repo } = parseRepo(repoString)
+
+ const targetRef = ref || (await getDefaultBranch(repoString))
+
+ const apiUrl = `${GITHUB_API}/repos/${owner}/${repo}/contents/${dirPath}?ref=${targetRef}`
+
+ const response = await fetch(apiUrl)
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`Directory not found: ${dirPath} in ${repoString}@${targetRef}`)
+ }
+ throw new Error(`Failed to list directory: ${response.status} ${response.statusText}`)
+ }
+
+ const data = (await response.json()) as Array<{
+ name: string
+ type: "file" | "dir"
+ path: string
+ }>
+
+ return data.map((item) => ({
+ name: item.name,
+ type: item.type,
+ path: item.path,
+ }))
+ }
+}
diff --git a/packages/opencode/src/marketplace/index.ts b/packages/opencode/src/marketplace/index.ts
new file mode 100644
index 00000000000..176fa23609a
--- /dev/null
+++ b/packages/opencode/src/marketplace/index.ts
@@ -0,0 +1,5 @@
+export { MarketplaceSchema } from "./schema"
+export { MarketplaceGitHub } from "./github"
+export { MarketplaceCache } from "./cache"
+export { MarketplaceDiscovery } from "./discovery"
+export { Marketplace } from "./marketplace"
diff --git a/packages/opencode/src/marketplace/marketplace.ts b/packages/opencode/src/marketplace/marketplace.ts
new file mode 100644
index 00000000000..fc7f7133002
--- /dev/null
+++ b/packages/opencode/src/marketplace/marketplace.ts
@@ -0,0 +1,353 @@
+import path from "path"
+import fs from "fs/promises"
+import { Config } from "../config/config"
+import { Global } from "../global"
+import { Instance } from "../project/instance"
+import { Log } from "../util/log"
+import { MarketplaceSchema } from "./schema"
+import { MarketplaceCache } from "./cache"
+import { MarketplaceGitHub } from "./github"
+import { NamedError } from "@opencode-ai/util/error"
+import z from "zod"
+import fuzzysort from "fuzzysort"
+
+export namespace Marketplace {
+ const log = Log.create({ service: "marketplace" })
+
+ // Errors
+ export const AgentExistsError = NamedError.create(
+ "MarketplaceAgentExistsError",
+ z.object({
+ path: z.string(),
+ name: z.string(),
+ }),
+ )
+
+ export const SourceNotFoundError = NamedError.create(
+ "MarketplaceSourceNotFoundError",
+ z.object({
+ repo: z.string(),
+ }),
+ )
+
+ export const AgentNotFoundError = NamedError.create(
+ "MarketplaceAgentNotFoundError",
+ z.object({
+ name: z.string(),
+ source: z.string().optional(),
+ }),
+ )
+
+ export const RegistryFetchError = NamedError.create(
+ "MarketplaceRegistryFetchError",
+ z.object({
+ repo: z.string(),
+ message: z.string(),
+ }),
+ )
+
+ // Agent with source info for display/installation
+ export interface MarketplaceAgent {
+ source: MarketplaceSchema.Source
+ agent: MarketplaceSchema.AgentEntry
+ installed: boolean
+ installedPath?: string
+ }
+
+ // Check if marketplace is enabled
+ export async function isEnabled(): Promise {
+ const config = await Config.get()
+ return config.marketplace?.enabled !== false
+ }
+
+ // List all configured sources
+ export async function listSources(): Promise {
+ const config = await Config.get()
+ const sources = config.marketplace?.sources ?? []
+ return sources.filter((s) => s.enabled !== false)
+ }
+
+ // Get a specific source by repo
+ export async function getSource(repo: string): Promise {
+ const sources = await listSources()
+ return sources.find((s) => s.repo === repo) ?? null
+ }
+
+ // Get all agents from all sources
+ export async function listAgents(options?: {
+ refresh?: boolean
+ source?: string
+ }): Promise {
+ const sources = await listSources()
+ const config = await Config.get()
+ const cacheDuration = config.marketplace?.cacheDuration ?? 3600000
+
+ const results: MarketplaceAgent[] = []
+ const installedAgents = await getInstalledAgents()
+
+ for (const source of sources) {
+ if (options?.source && source.repo !== options.source) continue
+
+ try {
+ const cached = await MarketplaceCache.getRegistry(source, {
+ refresh: options?.refresh,
+ cacheDuration,
+ })
+
+ for (const agent of cached.registry.agents ?? []) {
+ const installedPath = installedAgents.get(`${source.repo}:${agent.path}`)
+ results.push({
+ source,
+ agent,
+ installed: !!installedPath,
+ installedPath,
+ })
+ }
+ } catch (error) {
+ log.error("failed to fetch registry", { repo: source.repo, error })
+ // Continue with other sources
+ }
+ }
+
+ return results
+ }
+
+ // Search agents across all sources
+ export async function searchAgents(query: string): Promise {
+ const agents = await listAgents()
+
+ if (!query.trim()) {
+ return agents
+ }
+
+ // Prepare data for fuzzy search
+ const searchableAgents = agents.map((a) => ({
+ ...a,
+ searchName: a.agent.name,
+ searchDescription: a.agent.description ?? "",
+ searchTags: (a.agent.tags ?? []).join(" "),
+ }))
+
+ const results = fuzzysort.go(query, searchableAgents, {
+ keys: ["searchName", "searchDescription", "searchTags"],
+ threshold: -10000,
+ })
+
+ return results.map((r) => r.obj)
+ }
+
+ // Get an agent by name (optionally filtered by source)
+ export async function getAgent(
+ name: string,
+ sourceRepo?: string,
+ ): Promise {
+ const agents = await listAgents({ source: sourceRepo })
+ return agents.find((a) => a.agent.name === name) ?? null
+ }
+
+ // Fetch agent content from source
+ async function fetchAgentContent(
+ source: MarketplaceSchema.Source,
+ agentPath: string,
+ ): Promise {
+ const basePath = source.path ? `${source.path}/${agentPath}` : agentPath
+
+ const { content } = await MarketplaceGitHub.getRawContent(source.repo, basePath, source.ref)
+
+ return content
+ }
+
+ // Get map of installed agents: "source:path" -> local path
+ async function getInstalledAgents(): Promise