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> { + const map = new Map() + const dataPath = path.join(Global.Path.data, "marketplace", "installed.json") + + try { + const data = await Bun.file(dataPath).json() + const records = z.array(MarketplaceSchema.InstalledAgent).parse(data) + for (const record of records) { + map.set(`${record.source}:${record.sourcePath}`, record.localPath) + } + } catch { + // No installed agents file + } + + return map + } + + // Save installed agent record + async function saveInstalledAgent(record: MarketplaceSchema.InstalledAgent): Promise { + const dataPath = path.join(Global.Path.data, "marketplace", "installed.json") + await fs.mkdir(path.dirname(dataPath), { recursive: true }) + + const existing = await getInstalledAgents() + existing.set(`${record.source}:${record.sourcePath}`, record.localPath) + + const records: MarketplaceSchema.InstalledAgent[] = [] + // Rebuild from map (we need to reconstruct full records) + const allData = await Bun.file(dataPath) + .json() + .catch(() => []) + const existingRecords = z.array(MarketplaceSchema.InstalledAgent).parse(allData).filter( + (r) => r.source !== record.source || r.sourcePath !== record.sourcePath, + ) + records.push(...existingRecords, record) + + await Bun.write(dataPath, JSON.stringify(records, null, 2)) + } + + // Remove installed agent record + async function removeInstalledAgent(source: string, sourcePath: string): Promise { + const dataPath = path.join(Global.Path.data, "marketplace", "installed.json") + + try { + const data = await Bun.file(dataPath).json() + const records = z.array(MarketplaceSchema.InstalledAgent).parse(data) + const filtered = records.filter((r) => r.source !== source || r.sourcePath !== sourcePath) + await Bun.write(dataPath, JSON.stringify(filtered, null, 2)) + } catch { + // File doesn't exist + } + } + + // Install an agent from a source + export async function installAgent(options: { + source: MarketplaceSchema.Source + agentPath: string + scope: "global" | "project" + force?: boolean + }): Promise { + const { source, agentPath, scope, force = false } = options + + log.info("installing agent", { source: source.repo, path: agentPath, scope }) + + // Fetch agent content from GitHub + const content = await fetchAgentContent(source, agentPath) + + // Determine target directory + const targetDir = + scope === "global" + ? path.join(Global.Path.config, "agent") + : path.join(Instance.worktree, ".opencode", "agent") + + // Extract agent filename + const filename = path.basename(agentPath) + const targetPath = path.join(targetDir, filename) + + // Check for conflicts + if (!force && (await Bun.file(targetPath).exists())) { + // Extract agent name from the content + const nameMatch = content.match(/^---[\s\S]*?name:\s*(.+?)[\s\n]/) + const name = nameMatch ? nameMatch[1].trim() : filename.replace(".md", "") + throw new AgentExistsError({ path: targetPath, name }) + } + + // Write agent file + await fs.mkdir(targetDir, { recursive: true }) + await Bun.write(targetPath, content) + + // Record installation + await saveInstalledAgent({ + source: source.repo, + sourcePath: agentPath, + localPath: targetPath, + installedRef: source.ref ?? "main", + installedAt: Date.now(), + }) + + log.info("installed agent", { source: source.repo, path: targetPath }) + return targetPath + } + + // Uninstall an agent + export async function uninstallAgent(localPath: string): Promise { + log.info("uninstalling agent", { path: localPath }) + + // Find and remove the installed record + const dataPath = path.join(Global.Path.data, "marketplace", "installed.json") + try { + const data = await Bun.file(dataPath).json() + const records = z.array(MarketplaceSchema.InstalledAgent).parse(data) + const record = records.find((r) => r.localPath === localPath) + if (record) { + await removeInstalledAgent(record.source, record.sourcePath) + } + } catch { + // No records file + } + + // Delete the agent file + await fs.rm(localPath, { force: true }) + + log.info("uninstalled agent", { path: localPath }) + } + + // Add a new source + export async function addSource(source: MarketplaceSchema.Source): Promise { + log.info("adding source", { repo: source.repo }) + + // Validate source by fetching registry + try { + await MarketplaceCache.refreshRegistry(source) + } catch (error) { + throw new RegistryFetchError({ + repo: source.repo, + message: error instanceof Error ? error.message : "Unknown error", + }) + } + + // Note: Actual config update would need to be done by the caller + // since we don't have a way to write back to config files here + log.info("source validated", { repo: source.repo }) + } + + // Validate a source + export async function validateSource( + source: MarketplaceSchema.Source, + ): Promise<{ valid: boolean; error?: string; registry?: MarketplaceSchema.RegistryIndex }> { + try { + const cached = await MarketplaceCache.refreshRegistry(source) + return { valid: true, registry: cached.registry } + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } + } + + // Refresh all sources + export async function refreshAllSources(): Promise<{ + success: string[] + failed: { repo: string; error: string }[] + }> { + const sources = await listSources() + const success: string[] = [] + const failed: { repo: string; error: string }[] = [] + + for (const source of sources) { + try { + await MarketplaceCache.refreshRegistry(source) + success.push(source.repo) + } catch (error) { + failed.push({ + repo: source.repo, + error: error instanceof Error ? error.message : "Unknown error", + }) + } + } + + return { success, failed } + } + + // Clear marketplace cache + export async function clearCache(): Promise { + await MarketplaceCache.clearAll() + log.info("marketplace cache cleared") + } + + // Check if GitHub authentication is available + export async function hasGitHubAuth(): Promise { + return MarketplaceGitHub.isAuthenticated() + } +} diff --git a/packages/opencode/src/marketplace/schema.ts b/packages/opencode/src/marketplace/schema.ts new file mode 100644 index 00000000000..63a4e7c5160 --- /dev/null +++ b/packages/opencode/src/marketplace/schema.ts @@ -0,0 +1,174 @@ +import z from "zod" + +export namespace MarketplaceSchema { + // Source configuration - a GitHub repository containing agents + export const Source = z + .object({ + // GitHub repository in format "owner/repo" or full URL + repo: z.string().describe("GitHub repository in format 'owner/repo'"), + // Optional branch/tag/commit (defaults to default branch) + ref: z.string().optional().describe("Branch, tag, or commit SHA (defaults to default branch)"), + // Optional subdirectory within repo + path: z.string().optional().describe("Subdirectory path within the repository"), + // Whether this is a private repository + private: z.boolean().optional().describe("Whether this is a private repository"), + // Enable/disable this source + enabled: z.boolean().optional().default(true).describe("Enable or disable this source"), + // Optional name override for display + name: z.string().optional().describe("Display name for this source"), + }) + .strict() + .meta({ + ref: "MarketplaceSource", + }) + export type Source = z.infer + + // Agent entry in a registry index + export const AgentEntry = z + .object({ + // Relative path to agent file from registry root + path: z.string().describe("Relative path to the agent markdown file"), + // Metadata extracted from frontmatter + name: z.string().describe("Agent name"), + description: z.string().optional().describe("Agent description"), + mode: z.enum(["subagent", "primary", "all"]).optional().describe("Agent mode"), + color: z.string().optional().describe("Hex color code for the agent"), + // Additional searchable tags + tags: z.array(z.string()).optional().describe("Searchable tags"), + // Version info + version: z.string().optional().describe("Semantic version"), + // Author info + author: z.string().optional().describe("Author name or GitHub username"), + }) + .strict() + .meta({ + ref: "MarketplaceAgentEntry", + }) + export type AgentEntry = z.infer + + // Skill entry (for future expansion) + export const SkillEntry = z + .object({ + path: z.string(), + name: z.string(), + description: z.string().optional(), + triggers: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + version: z.string().optional(), + author: z.string().optional(), + }) + .strict() + .meta({ + ref: "MarketplaceSkillEntry", + }) + export type SkillEntry = z.infer + + // Plugin entry (for future expansion) + export const PluginEntry = z + .object({ + path: z.string(), + name: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + version: z.string().optional(), + author: z.string().optional(), + }) + .strict() + .meta({ + ref: "MarketplacePluginEntry", + }) + export type PluginEntry = z.infer + + // MCP entry (for future expansion) + export const McpEntry = z + .object({ + path: z.string(), + name: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + version: z.string().optional(), + author: z.string().optional(), + }) + .strict() + .meta({ + ref: "MarketplaceMcpEntry", + }) + export type McpEntry = z.infer + + // Registry index file schema (registry.json in source repos) + export const RegistryIndex = z + .object({ + version: z.literal("1").describe("Registry schema version"), + name: z.string().describe("Registry name"), + description: z.string().optional().describe("Registry description"), + // Content types available in this registry + types: z + .array(z.enum(["agent", "skill", "plugin", "tool", "mcp"])) + .default(["agent"]) + .describe("Content types available in this registry"), + // List of agents with metadata + agents: z.array(AgentEntry).optional().describe("Available agents"), + // Future: skills, plugins, tools, mcp servers + skills: z.array(SkillEntry).optional().describe("Available skills"), + plugins: z.array(PluginEntry).optional().describe("Available plugins"), + mcp: z.array(McpEntry).optional().describe("Available MCP server configurations"), + }) + .strict() + .meta({ + ref: "MarketplaceRegistry", + }) + export type RegistryIndex = z.infer + + // Cached source metadata + export const CachedSource = z + .object({ + source: Source, + registry: RegistryIndex, + lastFetched: z.number().describe("Unix timestamp of last fetch"), + etag: z.string().optional().describe("ETag for conditional requests"), + }) + .strict() + .meta({ + ref: "MarketplaceCachedSource", + }) + export type CachedSource = z.infer + + // Installed agent record + export const InstalledAgent = z + .object({ + // Source repo reference + source: z.string().describe("Source repository"), + // Path within source + sourcePath: z.string().describe("Path within source repository"), + // Local installation path + localPath: z.string().describe("Local file path where agent is installed"), + // Version/commit at installation + installedRef: z.string().describe("Git ref at time of installation"), + // Installation timestamp + installedAt: z.number().describe("Unix timestamp of installation"), + }) + .strict() + .meta({ + ref: "MarketplaceInstalledAgent", + }) + export type InstalledAgent = z.infer + + // Marketplace config for Config.Info + export const Config = z + .object({ + sources: z.array(Source).optional().describe("Marketplace sources (GitHub repositories)"), + // Cache duration in milliseconds (default: 1 hour) + cacheDuration: z + .number() + .optional() + .default(3600000) + .describe("Cache duration in milliseconds (default: 1 hour)"), + // Enable marketplace features + enabled: z.boolean().optional().default(true).describe("Enable marketplace features"), + }) + .strict() + .meta({ + ref: "MarketplaceConfig", + }) + export type Config = z.infer +} diff --git a/packages/opencode/src/server/marketplace.ts b/packages/opencode/src/server/marketplace.ts new file mode 100644 index 00000000000..53f0f9b9e18 --- /dev/null +++ b/packages/opencode/src/server/marketplace.ts @@ -0,0 +1,250 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Marketplace, MarketplaceSchema } from "../marketplace" +import { errors } from "./error" + +export const MarketplaceRoute = new Hono() + .get( + "/sources", + describeRoute({ + summary: "List marketplace sources", + description: "List all configured marketplace sources (GitHub repositories).", + operationId: "marketplace.sources.list", + responses: { + 200: { + description: "List of marketplace sources", + content: { + "application/json": { + schema: resolver(z.array(MarketplaceSchema.Source)), + }, + }, + }, + }, + }), + async (c) => { + const sources = await Marketplace.listSources() + return c.json(sources) + }, + ) + .post( + "/sources/validate", + describeRoute({ + summary: "Validate marketplace source", + description: "Validate a marketplace source by fetching its registry.", + operationId: "marketplace.sources.validate", + responses: { + 200: { + description: "Validation result", + content: { + "application/json": { + schema: resolver( + z.object({ + valid: z.boolean(), + error: z.string().optional(), + registry: MarketplaceSchema.RegistryIndex.optional(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", MarketplaceSchema.Source), + async (c) => { + const source = c.req.valid("json") + const result = await Marketplace.validateSource(source) + return c.json(result) + }, + ) + .get( + "/agents", + describeRoute({ + summary: "List marketplace agents", + description: "List all available agents from configured marketplace sources.", + operationId: "marketplace.agents.list", + responses: { + 200: { + description: "List of available agents", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + source: MarketplaceSchema.Source, + agent: MarketplaceSchema.AgentEntry, + installed: z.boolean(), + installedPath: z.string().optional(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const refresh = c.req.query("refresh") === "true" + const source = c.req.query("source") + const agents = await Marketplace.listAgents({ refresh, source }) + return c.json(agents) + }, + ) + .get( + "/agents/search", + describeRoute({ + summary: "Search marketplace agents", + description: "Search for agents across all marketplace sources.", + operationId: "marketplace.agents.search", + responses: { + 200: { + description: "Search results", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + source: MarketplaceSchema.Source, + agent: MarketplaceSchema.AgentEntry, + installed: z.boolean(), + installedPath: z.string().optional(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const query = c.req.query("q") ?? "" + const results = await Marketplace.searchAgents(query) + return c.json(results) + }, + ) + .post( + "/agents/install", + describeRoute({ + summary: "Install agent from marketplace", + description: "Install an agent from a marketplace source.", + operationId: "marketplace.agents.install", + responses: { + 200: { + description: "Agent installed successfully", + content: { + "application/json": { + schema: resolver( + z.object({ + path: z.string(), + }), + ), + }, + }, + }, + ...errors(400, 409), + }, + }), + validator( + "json", + z.object({ + source: MarketplaceSchema.Source, + agentPath: z.string(), + scope: z.enum(["global", "project"]), + force: z.boolean().optional(), + }), + ), + async (c) => { + const { source, agentPath, scope, force } = c.req.valid("json") + const path = await Marketplace.installAgent({ source, agentPath, scope, force }) + return c.json({ path }) + }, + ) + .delete( + "/agents/uninstall", + describeRoute({ + summary: "Uninstall marketplace agent", + description: "Uninstall an agent that was installed from the marketplace.", + operationId: "marketplace.agents.uninstall", + responses: { + 200: { + description: "Agent uninstalled successfully", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + localPath: z.string(), + }), + ), + async (c) => { + const { localPath } = c.req.valid("json") + await Marketplace.uninstallAgent(localPath) + return c.json({ success: true as const }) + }, + ) + .post( + "/refresh", + describeRoute({ + summary: "Refresh marketplace sources", + description: "Refresh all marketplace source registries.", + operationId: "marketplace.refresh", + responses: { + 200: { + description: "Refresh results", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.array(z.string()), + failed: z.array( + z.object({ + repo: z.string(), + error: z.string(), + }), + ), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const result = await Marketplace.refreshAllSources() + return c.json(result) + }, + ) + .get( + "/auth", + describeRoute({ + summary: "Check GitHub auth status", + description: "Check if GitHub authentication is available for private repositories.", + operationId: "marketplace.auth.status", + responses: { + 200: { + description: "Authentication status", + content: { + "application/json": { + schema: resolver( + z.object({ + authenticated: z.boolean(), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const authenticated = await Marketplace.hasGitHubAuth() + return c.json({ authenticated }) + }, + ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 32d7a179555..573fb594e27 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -52,6 +52,7 @@ import { QuestionRoute } from "./question" import { Installation } from "@/installation" import { MDNS } from "./mdns" import { Worktree } from "../worktree" +import { MarketplaceRoute } from "./marketplace" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -74,7 +75,7 @@ export namespace Server { const app = new Hono() export const App: () => Hono = lazy( () => - // TODO: Break server.ts into smaller route files to fix type inference + // @ts-expect-error TS2589: Route chain is too deep for TypeScript, but works at runtime app .onError((err, c) => { log.error("failed", { @@ -2818,6 +2819,7 @@ export namespace Server { }) }, ) + .route("/marketplace", MarketplaceRoute) .all("/*", async (c) => { const path = c.req.path const response = await proxy(`https://app.opencode.ai${path}`, { @@ -2827,10 +2829,6 @@ export namespace Server { host: "app.opencode.ai", }, }) - response.headers.set( - "Content-Security-Policy", - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'", - ) return response }) as unknown as Hono, )