diff --git a/examples/plugin-mcp-demo/plugin.ts b/examples/plugin-mcp-demo/plugin.ts new file mode 100644 index 000000000000..03bcde54c978 --- /dev/null +++ b/examples/plugin-mcp-demo/plugin.ts @@ -0,0 +1,93 @@ +import { type Plugin, mcpTool, mcpServer } from "@opencode-ai/plugin" +import { z } from "zod" + +/** + * Example plugin demonstrating the tools and mcp bridges. + * + * This plugin shows how to: + * 1. Call built-in tools from a plugin + * 2. Add and use MCP servers + * 3. Create wrapped MCP tools + */ +export const DemoPlugin: Plugin = async (ctx) => { + // Example: Add a local MCP server (uncomment when you have one to test) + // await ctx.mcp.addServer("my-tools", mcpServer.local({ + // command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + // })) + + return { + tool: { + // Example: Tool that uses built-in tools + countFiles: { + description: "Count files matching a pattern", + args: { + pattern: z.string().default("**/*.ts"), + }, + async execute(args) { + const result = await ctx.tools.call("glob", { pattern: args.pattern }) + const files = result.output as string[] + return `Found ${files.length} files matching ${args.pattern}` + }, + }, + + // Example: Tool that reads a file using built-in tool + peekFile: { + description: "Read first 10 lines of a file", + args: { + filePath: z.string(), + }, + async execute(args) { + const result = await ctx.tools.call("read", { + filePath: args.filePath, + limit: 10, + }) + return result.output as string + }, + }, + + // Example: List all available tools + listTools: { + description: "List all available tools", + args: {}, + async execute() { + const tools = await ctx.tools.list() + const lines = tools.map((t) => `- ${t.id} (${t.source}): ${t.description.slice(0, 50)}...`) + return `Available tools:\n${lines.join("\n")}` + }, + }, + + // Example: Check MCP server status + mcpStatus: { + description: "Show MCP server connection status", + args: {}, + async execute() { + const status = await ctx.mcp.status() + const entries = Object.entries(status) + if (entries.length === 0) { + return "No MCP servers configured" + } + const lines = entries.map(([name, s]) => `- ${name}: ${s.status}`) + return `MCP Servers:\n${lines.join("\n")}` + }, + }, + + // Example: Wrapped MCP tool (uncomment when server is added) + // everything: mcpTool(ctx, { + // server: "my-tools", + // tool: "echo", + // description: "Echo via MCP", + // }), + }, + + // Example: Using tools from event hook + event: async ({ event }) => { + if (event.type === "session.created") { + console.log("[demo-plugin] Session created, checking tools...") + const hasRead = await ctx.tools.has("read") + console.log(`[demo-plugin] Read tool available: ${hasRead}`) + } + }, + } +} + +export default DemoPlugin diff --git a/packages/app/src/components/virtualized-message-list.test.ts b/packages/app/src/components/virtualized-message-list.test.ts new file mode 100644 index 000000000000..52283205d19d --- /dev/null +++ b/packages/app/src/components/virtualized-message-list.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" + +describe("VirtualizedMessageList", () => { + test("component file exists and exports correctly", async () => { + const code = await Bun.file("src/components/virtualized-message-list.tsx").text() + expect(code).toContain("export function VirtualizedMessageList") + expect(code).toContain("VirtualizedMessageListHandle") + expect(code).toContain("scrollToIndex") + expect(code).toContain("VList") + expect(code).toContain('from "virtua/solid"') + }) + + test("exposes scrollToIndex method via ref", async () => { + const code = await Bun.file("src/components/virtualized-message-list.tsx").text() + // Verify the ref callback pattern is implemented + expect(code).toContain("props.ref") + expect(code).toContain("handle") + expect(code).toContain("scrollToIndex") + // Verify it delegates to VList ref + expect(code).toContain("listRef?.scrollToIndex") + }) +}) diff --git a/packages/app/src/components/virtualized-message-list.tsx b/packages/app/src/components/virtualized-message-list.tsx new file mode 100644 index 000000000000..f06c4b8528c7 --- /dev/null +++ b/packages/app/src/components/virtualized-message-list.tsx @@ -0,0 +1,29 @@ +import type { JSX } from "solid-js" +import { VList, type VListHandle } from "virtua/solid" + +type Message = { id: string; role: string } + +export type VirtualizedMessageListHandle = { + scrollToIndex: (index: number, opts?: { align?: "start" | "center" | "end" | "nearest" }) => void +} + +export function VirtualizedMessageList(props: { + messages: T[] + renderMessage: (message: T) => JSX.Element + overscan?: number + ref?: (handle: VirtualizedMessageListHandle) => void +}) { + let listRef: VListHandle | undefined + + const handle: VirtualizedMessageListHandle = { + scrollToIndex: (index, opts) => listRef?.scrollToIndex(index, opts), + } + + if (props.ref) props.ref(handle) + + return ( + (listRef = r)} data={props.messages} overscan={props.overscan ?? 4}> + {(message) => props.renderMessage(message)} + + ) +} diff --git a/packages/app/src/components/virtualized-session-list.test.tsx b/packages/app/src/components/virtualized-session-list.test.tsx new file mode 100644 index 000000000000..f740f8c5da71 --- /dev/null +++ b/packages/app/src/components/virtualized-session-list.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" + +describe("VirtualizedSessionList", () => { + test("component exports VirtualizedSessionList", async () => { + const code = await Bun.file( + "src/components/virtualized-session-list.tsx", + ).text() + expect(code).toContain("export function VirtualizedSessionList") + expect(code).toContain("VList") + expect(code).toContain("virtua/solid") + }) + + test("component accepts sessions and renderSession props", async () => { + const code = await Bun.file( + "src/components/virtualized-session-list.tsx", + ).text() + expect(code).toContain("sessions:") + expect(code).toContain("renderSession:") + expect(code).toContain("overscan") + }) +}) diff --git a/packages/app/src/components/virtualized-session-list.tsx b/packages/app/src/components/virtualized-session-list.tsx new file mode 100644 index 000000000000..558defcd9daf --- /dev/null +++ b/packages/app/src/components/virtualized-session-list.tsx @@ -0,0 +1,16 @@ +import { type JSX } from "solid-js" +import { VList } from "virtua/solid" + +type Session = { id: string; directory: string; time: { created: number } } + +export function VirtualizedSessionList(props: { + sessions: T[] + renderSession: (session: T) => JSX.Element + overscan?: number +}) { + return ( + + {(session) => props.renderSession(session)} + + ) +} diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts new file mode 100644 index 000000000000..3cec0699221e --- /dev/null +++ b/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test" + +describe("global-sync render optimizations", () => { + test("exposes sortedSessions memo", async () => { + const code = await Bun.file("src/context/global-sync.tsx").text() + expect(code).toContain("sortedSessions") + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c32..0b8fa6642143 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -282,6 +282,21 @@ function createGlobalSync() { }) const children: Record, SetStoreFunction]> = {} + const MAX_CHILD_STORES = 10 + const access: string[] = [] + + const evict = () => { + const oldest = access.shift() + if (!oldest) return + delete children[oldest] + } + + const touch = (directory: string) => { + const idx = access.indexOf(directory) + if (idx > -1) access.splice(idx, 1) + access.push(directory) + } + const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() @@ -348,6 +363,9 @@ function createGlobalSync() { function ensureChild(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { + if (Object.keys(children).length >= MAX_CHILD_STORES) { + evict() + } const vcs = runWithOwner(owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -425,6 +443,7 @@ function createGlobalSync() { } const childStore = children[directory] if (!childStore) throw new Error("Failed to create store") + touch(directory) return childStore } @@ -1048,6 +1067,25 @@ function createGlobalSync() { setStore("icon", value) } + function sortedSessions(directory: string) { + const [store] = child(directory, { bootstrap: false }) + const now = Date.now() + const oneMinuteAgo = now - 60 * 1000 + return store.session + .filter((session) => session.directory === store.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted((a, b) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + }) + } + return { data: globalStore, set: setGlobalStore, @@ -1059,6 +1097,7 @@ function createGlobalSync() { }, child, bootstrap, + sortedSessions, updateConfig: (config: Config) => { setGlobalStore("reload", "pending") return globalSDK.client.global.config.update({ config }).finally(() => { diff --git a/packages/app/src/context/sync.test.ts b/packages/app/src/context/sync.test.ts new file mode 100644 index 000000000000..928ccbcfa1f2 --- /dev/null +++ b/packages/app/src/context/sync.test.ts @@ -0,0 +1,9 @@ +// packages/app/src/context/sync.test.ts +import { describe, expect, test } from "bun:test"; + +describe("sync meta cleanup", () => { + test("cleanupMeta function exists", async () => { + const code = await Bun.file(import.meta.dir + "/sync.tsx").text(); + expect(code).toContain("cleanupMeta"); + }); +}); diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5c8e140c396a..a7f7400be844 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -289,6 +289,51 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, + cleanupSessionCaches(sessionID: string) { + const [store, setStore] = current() + if (!sessionID) return + + const hasAny = + store.message[sessionID] !== undefined || + store.session_diff[sessionID] !== undefined || + store.todo[sessionID] !== undefined || + store.permission[sessionID] !== undefined || + store.question[sessionID] !== undefined || + store.session_status[sessionID] !== undefined + + if (!hasAny) return + + setStore( + produce((draft) => { + const messages = draft.message[sessionID] + if (messages) { + for (const message of messages) { + const id = message?.id + if (!id) continue + delete draft.part[id] + } + } + + delete draft.message[sessionID] + delete draft.session_diff[sessionID] + delete draft.todo[sessionID] + delete draft.permission[sessionID] + delete draft.question[sessionID] + delete draft.session_status[sessionID] + }), + ) + }, + cleanupMeta(sessionID: string) { + const directory = sdk.directory + const key = keyFor(directory, sessionID) + setMeta( + produce((draft) => { + delete draft.limit[key] + delete draft.complete[key] + delete draft.loading[key] + }), + ) + }, }, absolute, get directory() { diff --git a/packages/app/src/pages/layout.test.ts b/packages/app/src/pages/layout.test.ts new file mode 100644 index 000000000000..995b2e805f29 --- /dev/null +++ b/packages/app/src/pages/layout.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" + +describe("layout session list virtualization", () => { + test("LocalWorkspace uses virtualized list when flag enabled", async () => { + const code = await Bun.file("src/pages/layout.tsx").text() + expect(code).toContain("VirtualizedSessionList") + expect(code).toContain("sessionListVirtualization") + }) + + test("SortableWorkspace uses virtualized list when flag enabled", async () => { + const code = await Bun.file("src/pages/layout.tsx").text() + // Count >() + const MAX_PREFETCH_DIRS = 20 + + const cleanupPrefetch = () => { + if (prefetchedByDir.size <= MAX_PREFETCH_DIRS) return + + // Get directories sorted by least recently added + const dirs = Array.from(prefetchedByDir.keys()) + const toRemove = dirs.slice(0, dirs.length - MAX_PREFETCH_DIRS) + + for (const dir of toRemove) { + prefetchQueues.delete(dir) + prefetchedByDir.delete(dir) + } + } + const lruFor = (directory: string) => { const existing = prefetchedByDir.get(directory) if (existing) return existing const created = new Map() prefetchedByDir.set(directory, created) + cleanupPrefetch() return created } @@ -1713,10 +1731,7 @@ export default function Layout(props: ParentProps) { const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined - const user = messages - .slice() - .reverse() - .find((m) => m.role === "user") + const user = findLast(messages, (m) => m.role === "user") if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) @@ -2008,12 +2023,7 @@ export default function Layout(props: ParentProps) { pendingRename: false, }) const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) + const sessions = createMemo(() => globalSync.sortedSessions(props.directory)) const children = createMemo(() => { const map = new Map() for (const session of workspaceStore.session) { @@ -2201,11 +2211,12 @@ export default function Layout(props: ParentProps) { - - {(session) => ( + ( )} - + />
- - {(message) => { - if (import.meta.env.DEV) { - onMount(() => { - const id = params.id - if (!id) return - navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) - }) - } - - return ( -
(virtualizedListRef = r)} + messages={renderedUserMessages()} + overscan={4} + renderMessage={(message) => ( +
+ + setStore("expanded", message.id, (open: boolean | undefined) => !open) + } + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", }} - > - - setStore("expanded", message.id, (open: boolean | undefined) => !open) - } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", - }} - /> -
- ) - }} - + /> +
+ )} + /> diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts new file mode 100644 index 000000000000..6b8f5afc897b --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createScrollSpy } from "./scroll-spy" +import { createRoot } from "solid-js" + +describe("createScrollSpy", () => { + test("tracks active message id", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy() + + spy.register("msg-1", { top: 0, height: 100 }) + spy.register("msg-2", { top: 100, height: 100 }) + spy.register("msg-3", { top: 200, height: 100 }) + + spy.updateScroll(0) + expect(spy.activeId()).toBe("msg-1") + + spy.updateScroll(150) + expect(spy.activeId()).toBe("msg-2") + + dispose() + resolve() + }) + }) + }) + + test("unregister removes message", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy() + + spy.register("msg-1", { top: 0, height: 100 }) + spy.unregister("msg-1") + + expect(spy.activeId()).toBeUndefined() + + dispose() + resolve() + }) + }) + }) + + test("supports IntersectionObserver mode", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy({ useObserver: true }) + + expect(spy.observe).toBeDefined() + expect(spy.unobserve).toBeDefined() + + dispose() + resolve() + }) + }) + }) + + test("supports resize observation", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy({ useObserver: true }) + + expect(spy.observeResize).toBeDefined() + + dispose() + resolve() + }) + }) + }) +}) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts new file mode 100644 index 000000000000..829626b7b42e --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -0,0 +1,118 @@ +import { createSignal, onCleanup } from "solid-js" + +type Position = { top: number; height: number } +type Options = { useObserver?: boolean; root?: HTMLElement } + +export function createScrollSpy(options: Options = {}) { + const positions = new Map() + const intersections = new Map() + const [activeId, setActiveId] = createSignal() + + let observer: IntersectionObserver | undefined + + let resizer: ResizeObserver | undefined + + if (options.useObserver && typeof IntersectionObserver !== "undefined") { + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.messageId + if (!id) continue + intersections.set(id, entry.intersectionRatio) + } + + let best: string | undefined + let ratio = 0 + + for (const [id, r] of intersections) { + if (r > ratio) { + ratio = r + best = id + } + } + + if (best && best !== activeId()) { + setActiveId(best) + } + }, + { + root: options.root ?? null, + threshold: [0, 0.25, 0.5, 0.75, 1], + }, + ) + + onCleanup(() => observer?.disconnect()) + } + + if (options.useObserver && typeof ResizeObserver !== "undefined") { + resizer = new ResizeObserver(() => { + // Positions will be refreshed via IntersectionObserver on next scroll + }) + + onCleanup(() => resizer?.disconnect()) + } + + const findActive = (scrollTop: number) => { + let active: string | undefined + let closest = Infinity + + for (const [id, pos] of positions) { + const distance = Math.abs(pos.top - scrollTop) + if (pos.top <= scrollTop + 100 && distance < closest) { + closest = distance + active = id + } + } + + return active + } + + return { + register(id: string, position: Position) { + positions.set(id, position) + }, + + unregister(id: string) { + positions.delete(id) + intersections.delete(id) + }, + + observe(element: HTMLElement) { + observer?.observe(element) + }, + + unobserve(element: HTMLElement) { + observer?.unobserve(element) + }, + + observeResize(element: HTMLElement) { + resizer?.observe(element) + }, + + unobserveResize(element: HTMLElement) { + resizer?.unobserve(element) + }, + + updateScroll(scrollTop: number) { + if (observer) return + const active = findActive(scrollTop) + if (active !== activeId()) { + setActiveId(active) + } + }, + + activeId, + + refresh(getPosition: (id: string) => Position | undefined) { + for (const id of positions.keys()) { + const pos = getPosition(id) + if (pos) positions.set(id, pos) + } + }, + + dispose() { + observer?.disconnect() + resizer?.disconnect() + }, + } +} diff --git a/packages/app/src/utils/cache.test.ts b/packages/app/src/utils/cache.test.ts new file mode 100644 index 000000000000..89aa7d12f779 --- /dev/null +++ b/packages/app/src/utils/cache.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { createLruCache } from "./cache" + +describe("createLruCache", () => { + test("evicts least recently used when at capacity", () => { + const cache = createLruCache({ maxEntries: 3 }) + + cache.set("a", "value-a") + cache.set("b", "value-b") + cache.set("c", "value-c") + + expect(cache.get("a")).toBe("value-a") + expect(cache.get("b")).toBe("value-b") + expect(cache.get("c")).toBe("value-c") + + // Access 'a' to make it recently used + cache.get("a") + + // Add 'd', should evict 'b' (least recently used) + cache.set("d", "value-d") + + expect(cache.get("a")).toBe("value-a") + expect(cache.get("b")).toBeUndefined() + expect(cache.get("c")).toBe("value-c") + expect(cache.get("d")).toBe("value-d") + }) + + test("respects TTL expiration", async () => { + const cache = createLruCache({ maxEntries: 10, ttlMs: 50 }) + + cache.set("key", "value") + expect(cache.get("key")).toBe("value") + + await new Promise((r) => setTimeout(r, 60)) + + expect(cache.get("key")).toBeUndefined() + }) + + test("calls onEvict callback", () => { + const evicted: string[] = [] + const cache = createLruCache({ + maxEntries: 2, + onEvict: (key, value) => evicted.push(`${key}:${value}`), + }) + + cache.set("a", "1") + cache.set("b", "2") + cache.set("c", "3") // evicts 'a' + + expect(evicted).toEqual(["a:1"]) + }) + + test("delete removes entry and calls onEvict", () => { + const evicted: string[] = [] + const cache = createLruCache({ + maxEntries: 10, + onEvict: (key) => evicted.push(key), + }) + + cache.set("a", "1") + cache.delete("a") + + expect(cache.get("a")).toBeUndefined() + expect(evicted).toEqual(["a"]) + }) + + test("clear removes all entries", () => { + const cache = createLruCache({ maxEntries: 10 }) + + cache.set("a", "1") + cache.set("b", "2") + cache.clear() + + expect(cache.get("a")).toBeUndefined() + expect(cache.get("b")).toBeUndefined() + expect(cache.stats().size).toBe(0) + }) + + test("stats returns current size and eviction count", () => { + const cache = createLruCache({ maxEntries: 2 }) + + cache.set("a", "1") + cache.set("b", "2") + expect(cache.stats()).toEqual({ size: 2, evictions: 0 }) + + cache.set("c", "3") + expect(cache.stats()).toEqual({ size: 2, evictions: 1 }) + }) +}) diff --git a/packages/app/src/utils/cache.ts b/packages/app/src/utils/cache.ts new file mode 100644 index 000000000000..6accd7668880 --- /dev/null +++ b/packages/app/src/utils/cache.ts @@ -0,0 +1,101 @@ +type CacheEntry = { + value: T + expiresAt: number | undefined +} + +type CacheOpts = { + maxEntries: number + ttlMs?: number + onEvict?: (key: string, value: T) => void +} + +export function createLruCache(opts: CacheOpts) { + const entries = new Map>() + const order: string[] = [] + let evictions = 0 + + const touch = (key: string) => { + const idx = order.indexOf(key) + if (idx > -1) order.splice(idx, 1) + order.push(key) + } + + const evictOldest = () => { + const oldest = order.shift() + if (!oldest) return + const entry = entries.get(oldest) + entries.delete(oldest) + evictions++ + if (entry && opts.onEvict) opts.onEvict(oldest, entry.value) + } + + const isExpired = (entry: CacheEntry) => { + if (!entry.expiresAt) return false + return Date.now() > entry.expiresAt + } + + const cache = { + get(key: string): T | undefined { + const entry = entries.get(key) + if (!entry) return undefined + if (isExpired(entry)) { + cache.delete(key) + return undefined + } + touch(key) + return entry.value + }, + + set(key: string, value: T) { + if (entries.has(key)) { + entries.set(key, { + value, + expiresAt: opts.ttlMs ? Date.now() + opts.ttlMs : undefined, + }) + touch(key) + return + } + + while (entries.size >= opts.maxEntries) { + evictOldest() + } + + entries.set(key, { + value, + expiresAt: opts.ttlMs ? Date.now() + opts.ttlMs : undefined, + }) + order.push(key) + }, + + delete(key: string) { + const entry = entries.get(key) + if (!entry) return + entries.delete(key) + const idx = order.indexOf(key) + if (idx > -1) order.splice(idx, 1) + evictions++ + if (opts.onEvict) opts.onEvict(key, entry.value) + }, + + clear() { + entries.clear() + order.length = 0 + }, + + has(key: string) { + const entry = entries.get(key) + if (!entry) return false + if (isExpired(entry)) { + cache.delete(key) + return false + } + return true + }, + + stats() { + return { size: entries.size, evictions } + }, + } + + return cache +} diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 1bf8b3700b93..3a543a2beced 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,6 +17,10 @@ "group": "Getting started", "pages": ["index", "quickstart", "development"], "openapi": "https://opencode.ai/openapi.json" + }, + { + "group": "Plugins", + "pages": ["plugins"] } ] } diff --git a/packages/docs/plugins.mdx b/packages/docs/plugins.mdx new file mode 100644 index 000000000000..1810f8f99f92 --- /dev/null +++ b/packages/docs/plugins.mdx @@ -0,0 +1,324 @@ +--- +title: "Plugins" +description: "Extend OpenCode with custom tools and MCP integrations" +--- + +# Building Plugins + +Plugins extend OpenCode with custom tools, hooks, and integrations. The `@opencode-ai/plugin` package provides full access to OpenCode's tool system and MCP servers. + +## Installation + +```bash +bun add @opencode-ai/plugin +``` + +## Basic Plugin + +A plugin is an async function that receives context and returns hooks: + +```typescript +import { type Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + greet: { + description: "Say hello", + args: {}, + async execute() { + return "Hello from my plugin!" + }, + }, + }, + } +} + +export default MyPlugin +``` + +## Plugin Context + +Every plugin receives a context object: + +| Property | Description | +| ----------- | ------------------------ | +| `client` | SDK client for API calls | +| `project` | Current project info | +| `directory` | Project root directory | +| `tools` | Call any registered tool | +| `mcp` | Manage MCP servers | +| `$` | Shell for commands | + +## Calling Tools + +The `tools` bridge lets plugins call any tool in the system. + +### Basic Usage + +```typescript +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + countFiles: { + description: "Count TypeScript files", + args: {}, + async execute() { + const result = await ctx.tools.call("glob", { + pattern: "**/*.ts", + }) + const files = result.output as string[] + return `Found ${files.length} files` + }, + }, + }, + } +} +``` + +### Available Methods + +```typescript +// Call a tool +const result = await ctx.tools.call("read", { filePath: "/path/to/file" }) + +// Call with timeout +await ctx.tools.call("bash", { command: "npm test" }, { timeout: 60000 }) + +// List all tools +const tools = await ctx.tools.list() + +// Check if tool exists +if (await ctx.tools.has("my-mcp-server_search")) { + // Use the tool +} +``` + +### Tool Sources + +Tools come from three sources: + +- **builtin**: Core tools (read, write, bash, glob, grep, etc.) +- **mcp**: Tools from connected MCP servers +- **plugin**: Tools from other plugins + +## MCP Integration + +Plugins can add, manage, and use MCP servers programmatically. + +### Adding Servers + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + // Add a local MCP server + await ctx.mcp.addServer( + "my-tools", + mcpServer.local({ + command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + }), + ) + + // Add a remote MCP server + await ctx.mcp.addServer( + "remote", + mcpServer.remote({ + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + }), + ) + + return { + /* hooks */ + } +} +``` + +### Server Management + +```typescript +// Check connection status +const status = await ctx.mcp.status() +// { "my-tools": { status: "connected" } } + +// Connect/disconnect +await ctx.mcp.connect("my-tools") +await ctx.mcp.disconnect("my-tools") + +// Remove server +await ctx.mcp.removeServer("my-tools") +``` + +### MCP Resources and Prompts + +```typescript +// Read a resource +const content = await ctx.mcp.readResource("server", "file:///path") + +// Get a prompt template +const prompt = await ctx.mcp.getPrompt("server", "review-code", { + language: "typescript", +}) +``` + +## Helper Functions + +### mcpTool + +Expose an MCP tool as a plugin tool: + +```typescript +import { mcpTool } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + search: mcpTool(ctx, { + server: "search-server", + tool: "search", + description: "Search the codebase", + transformArgs: (args) => ({ ...args, limit: 10 }), + }), + }, + } +} +``` + +### mcpServer + +Create server configurations: + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +// Local server (stdio) +mcpServer.local({ + command: ["python", "-m", "my_server"], + environment: { API_KEY: "xxx" }, + timeout: 60000, +}) + +// Remote server (SSE) with OAuth +mcpServer.remote({ + url: "https://api.example.com/mcp", + oauth: { clientId: "xxx", scope: "read" }, +}) +``` + +## Error Handling + +The SDK provides typed errors: + +```typescript +import { ToolNotFoundError, ToolPermissionError, McpNotConnectedError, McpTimeoutError } from "@opencode-ai/plugin" + +try { + await ctx.tools.call("unknown", {}) +} catch (e) { + if (e instanceof ToolNotFoundError) { + console.log(`Available tools: ${e.availableTools.join(", ")}`) + } +} +``` + +| Error | Cause | +| ---------------------- | ----------------------- | +| `ToolNotFoundError` | Tool doesn't exist | +| `ToolPermissionError` | Plugin lacks permission | +| `ToolCycleError` | Circular call detected | +| `McpNotConnectedError` | Server not connected | +| `McpTimeoutError` | Call timed out | +| `McpAuthError` | Auth required | + +## Permissions + +Restrict plugin tool access via configuration: + +```json +{ + "plugins": { + "my-plugin": { + "permissions": { + "tools": { + "allow": ["read", "glob"], + "deny": ["bash", "write"] + } + } + } + } +} +``` + +## Event Hooks + +React to OpenCode events: + +```typescript +export const MyPlugin: Plugin = async (ctx) => { + return { + event: async ({ event }) => { + if (event.type === "session.created") { + console.log("New session started") + } + }, + } +} +``` + +## Complete Example + +```typescript +import { type Plugin, mcpTool, mcpServer } from "@opencode-ai/plugin" +import { z } from "zod" + +export const CodeAnalyzer: Plugin = async (ctx) => { + // Add analysis server + await ctx.mcp.addServer( + "analyzer", + mcpServer.local({ + command: ["npx", "code-analyzer-mcp"], + }), + ) + + return { + tool: { + // Custom tool using built-in tools + fileStats: { + description: "Get file statistics", + args: { + pattern: z.string().default("**/*.ts"), + }, + async execute(args) { + const glob = await ctx.tools.call("glob", { pattern: args.pattern }) + const files = glob.output as string[] + + let totalLines = 0 + for (const file of files.slice(0, 10)) { + const content = await ctx.tools.call("read", { filePath: file }) + totalLines += (content.output as string).split("\n").length + } + + return `${files.length} files, ~${totalLines} lines (sampled)` + }, + }, + + // Wrapped MCP tool + analyze: mcpTool(ctx, { + server: "analyzer", + tool: "analyze", + description: "Run code analysis", + }), + }, + + event: async ({ event }) => { + if (event.type === "session.created") { + const status = await ctx.mcp.status() + console.log("Analyzer status:", status.analyzer?.status) + } + }, + } +} + +export default CodeAnalyzer +``` diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e3079574..e2358c7b6914 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -862,6 +862,12 @@ export namespace Config { export const Provider = ModelsDev.Provider.partial() .extend({ + inherit: z + .string() + .optional() + .describe( + "Base provider ID to inherit models from. The inherited provider's models will be cloned under this provider's ID with custom credentials.", + ), whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), models: z diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6032935f8480..8f207803a937 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,8 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { createToolsBridge } from "./tools-bridge" +import { createMcpBridge } from "./mcp-bridge" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -35,6 +37,8 @@ export namespace Plugin { directory: Instance.directory, serverUrl: Server.url(), $: Bun.$, + tools: createToolsBridge({ pluginName: "internal" }), + mcp: createMcpBridge(), } for (const plugin of INTERNAL_PLUGINS) { diff --git a/packages/opencode/src/plugin/mcp-bridge.ts b/packages/opencode/src/plugin/mcp-bridge.ts new file mode 100644 index 000000000000..8c7dd1c9a5bb --- /dev/null +++ b/packages/opencode/src/plugin/mcp-bridge.ts @@ -0,0 +1,117 @@ +import type { + McpBridge, + McpServerConfig, + McpStatus, + ResourceContent, + PromptResult, + ToolInfo, +} from "@opencode-ai/plugin" +import { McpNotConnectedError, McpAuthError } from "@opencode-ai/plugin" +import { MCP } from "../mcp" +import type { Config } from "../config/config" + +export function createMcpBridge(): McpBridge { + return { + async addServer(name: string, config: McpServerConfig): Promise { + const mcpConfig: Config.Mcp = + config.type === "local" + ? { + type: "local", + command: config.command, + environment: config.environment, + timeout: config.timeout, + } + : { + type: "remote", + url: config.url, + headers: config.headers, + oauth: config.oauth, + } + + await MCP.add(name, mcpConfig) + }, + + async removeServer(name: string): Promise { + await MCP.disconnect(name) + }, + + async status(): Promise> { + const result = await MCP.status() + return result as Record + }, + + async connect(name: string): Promise { + await MCP.connect(name) + }, + + async disconnect(name: string): Promise { + await MCP.disconnect(name) + }, + + async tools(server: string): Promise { + const status = await MCP.status() + const serverStatus = status[server] + + if (!serverStatus) { + throw new McpNotConnectedError(server) + } + if (serverStatus.status === "needs_auth") { + throw new McpAuthError(server) + } + if (serverStatus.status !== "connected") { + throw new McpNotConnectedError(server) + } + + const allTools = await MCP.tools() + const result: ToolInfo[] = [] + const prefix = `${server}_` + + for (const [id, tool] of Object.entries(allTools)) { + if (id.startsWith(prefix)) { + result.push({ + id, + description: tool.description ?? "", + parameters: {}, + source: "mcp", + server, + }) + } + } + + return result + }, + + async readResource(server: string, uri: string): Promise { + const result = await MCP.readResource(server, uri) + if (!result) { + throw new McpNotConnectedError(server) + } + // Transform MCP SDK result to our interface + const contents = result.contents?.[0] as + | { uri?: string; mimeType?: string; text?: string; blob?: string } + | undefined + return { + uri: contents?.uri ?? uri, + mimeType: contents?.mimeType, + text: contents?.text, + blob: contents?.blob, + } + }, + + async getPrompt(server: string, name: string, args?: Record): Promise { + const result = await MCP.getPrompt(server, name, args) + if (!result) { + throw new McpNotConnectedError(server) + } + // Transform MCP SDK result to our interface + return { + description: result.description, + messages: + result.messages?.map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content as any, + })) ?? [], + } + }, + } +} diff --git a/packages/opencode/src/plugin/tools-bridge.ts b/packages/opencode/src/plugin/tools-bridge.ts new file mode 100644 index 000000000000..1c46c6665bc3 --- /dev/null +++ b/packages/opencode/src/plugin/tools-bridge.ts @@ -0,0 +1,191 @@ +import type { ToolsBridge, ToolCallOptions, ToolResult, ToolInfo } from "@opencode-ai/plugin" +import { ToolNotFoundError, ToolPermissionError, ToolCycleError, DEFAULT_TOOL_TIMEOUT } from "@opencode-ai/plugin" +import { ToolRegistry } from "../tool/registry" +import { MCP } from "../mcp" +import type { Tool } from "../tool/tool" + +export interface ToolsBridgeConfig { + pluginName: string + permissions?: { + tools?: { + allow?: string[] + deny?: string[] + } + } + sessionID?: string + messageID?: string + agent?: string + abort?: AbortSignal +} + +const callStacks = new WeakMap>() + +export function createToolsBridge(config: ToolsBridgeConfig): ToolsBridge { + const bridge: ToolsBridge = { + async call( + id: string, + args: Record, + options?: ToolCallOptions, + ): Promise> { + // Check permissions + if (!options?.skipPermissions) { + if (!checkPermission(id, config.permissions)) { + throw new ToolPermissionError(id, config.pluginName) + } + } + + // Cycle detection + let stack = callStacks.get(bridge) + if (!stack) { + stack = new Set() + callStacks.set(bridge, stack) + } + if (stack.has(id)) { + throw new ToolCycleError([...stack, id]) + } + stack.add(id) + + try { + const timeout = options?.timeout ?? DEFAULT_TOOL_TIMEOUT + const context = createContext(config, options) + + // Try built-in tools first + const registryTools = await ToolRegistry.tools({ providerID: "opencode", modelID: "default" }) + const builtinTool = registryTools.find((t) => t.id === id) + + if (builtinTool) { + const result = await Promise.race([ + builtinTool.execute(args, context), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Tool ${id} timed out after ${timeout}ms`)), timeout), + ), + ]) + + return { + output: result.output as T, + title: result.title, + metadata: result.metadata as Record, + } + } + + // Try MCP tools + const mcpTools = await MCP.tools() + const mcpTool = mcpTools[id] + + if (mcpTool && mcpTool.execute) { + const mcpResult = await Promise.race([ + mcpTool.execute(args, { toolCallId: crypto.randomUUID(), messages: [] }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Tool ${id} timed out after ${timeout}ms`)), timeout), + ), + ]) + + // MCP tools return { content: [...] } + if (typeof mcpResult === "object" && mcpResult !== null && "content" in mcpResult) { + const content = (mcpResult as { content: Array<{ type: string; text?: string }> }).content + const textParts = content.filter((c) => c.type === "text").map((c) => c.text ?? "") + return { + output: textParts.join("\n") as T, + } + } + + return { + output: mcpResult as T, + } + } + + // Tool not found + const availableBuiltin = registryTools.map((t) => t.id) + const availableMcp = Object.keys(mcpTools) + throw new ToolNotFoundError(id, [...availableBuiltin, ...availableMcp]) + } finally { + stack.delete(id) + } + }, + + async list(): Promise { + const result: ToolInfo[] = [] + + // Built-in tools + const registryTools = await ToolRegistry.tools({ providerID: "opencode", modelID: "default" }) + for (const tool of registryTools) { + result.push({ + id: tool.id, + description: tool.description, + parameters: tool.parameters as any, + source: "builtin", + }) + } + + // MCP tools + const mcpTools = await MCP.tools() + for (const [id, tool] of Object.entries(mcpTools)) { + result.push({ + id, + description: tool.description ?? "", + parameters: {}, + source: "mcp", + server: id.split("_")[0], + }) + } + + return result + }, + + async has(id: string): Promise { + const registryTools = await ToolRegistry.tools({ providerID: "opencode", modelID: "default" }) + if (registryTools.some((t) => t.id === id)) return true + + const mcpTools = await MCP.tools() + return id in mcpTools + }, + } + + return bridge +} + +function checkPermission(toolId: string, permissions?: { tools?: { allow?: string[]; deny?: string[] } }): boolean { + if (!permissions?.tools) return true + + // Deny list takes precedence + if (permissions.tools.deny?.includes(toolId)) { + return false + } + + // If allow list exists, it's a whitelist + if (permissions.tools.allow) { + return permissions.tools.allow.includes(toolId) + } + + return true +} + +function createContext(config: ToolsBridgeConfig, options?: ToolCallOptions): Tool.Context { + const callId = crypto.randomUUID() + + if (config.sessionID) { + // Use session context + return { + sessionID: config.sessionID, + messageID: config.messageID ?? `plugin-${callId}`, + agent: config.agent ?? "default", + abort: options?.signal ?? config.abort ?? new AbortController().signal, + callID: callId, + messages: [], + metadata: () => {}, + ask: async () => {}, + } + } + + // Synthetic context + return { + sessionID: `plugin-${config.pluginName}-${Date.now()}`, + messageID: `plugin-call-${callId}`, + agent: "default", + abort: options?.signal ?? new AbortController().signal, + callID: callId, + messages: [], + metadata: () => {}, + ask: async () => {}, + } +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e79cb1708947..f120b3f81349 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -712,6 +712,30 @@ export namespace Provider { } } + // Process inherit directives from config - clone base providers with new IDs + for (const [providerID, providerConfig] of configProviders) { + if (!providerConfig.inherit) continue + if (!isProviderAllowed(providerID)) continue + + const base = database[providerConfig.inherit] + if (!base) { + log.warn(`inherit: base provider '${providerConfig.inherit}' not found for '${providerID}'`) + continue + } + + database[providerID] = { + ...base, + id: providerID, + name: providerConfig.name ?? base.name, + source: "config", + models: mapValues(base.models, (model) => ({ + ...model, + providerID, + api: { ...model.api }, + })), + } + } + function mergeProvider(providerID: string, provider: Partial) { const existing = providers[providerID] if (existing) { diff --git a/packages/opencode/test/plugin/mcp-bridge.test.ts b/packages/opencode/test/plugin/mcp-bridge.test.ts new file mode 100644 index 000000000000..9ed830a867d6 --- /dev/null +++ b/packages/opencode/test/plugin/mcp-bridge.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { createMcpBridge } from "../../src/plugin/mcp-bridge" +import { McpNotConnectedError } from "@opencode-ai/plugin" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("McpBridge", () => { + describe("status", () => { + test("returns object of server statuses", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + const status = await bridge.status() + + expect(typeof status).toBe("object") + // May be empty if no MCP servers configured + }, + }) + }) + }) + + describe("tools", () => { + test("throws McpNotConnectedError for non-existent server", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + + await expect(bridge.tools("nonexistent-server-12345")).rejects.toThrow(McpNotConnectedError) + }, + }) + }) + }) + + describe("server management", () => { + test("addServer does not throw for valid config", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + + // This will attempt to connect but may fail - we just verify it completes + // In a real test environment, we'd mock the MCP module + const result = await bridge.addServer("test-server", { + type: "local", + command: ["echo", "test"], + }) + // Should resolve (may be undefined, that's ok) + expect(result).toBeUndefined() + }, + }) + }) + + test("disconnect does not throw for unknown server", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + + // Should not throw even for unknown server + const result = await bridge.disconnect("unknown-server") + // Should resolve (may be undefined, that's ok) + expect(result).toBeUndefined() + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/plugin/tools-bridge.test.ts b/packages/opencode/test/plugin/tools-bridge.test.ts new file mode 100644 index 000000000000..d59a6908988e --- /dev/null +++ b/packages/opencode/test/plugin/tools-bridge.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { createToolsBridge } from "../../src/plugin/tools-bridge" +import { ToolPermissionError } from "@opencode-ai/plugin" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("ToolsBridge", () => { + describe("permissions", () => { + test("denies tool in deny list", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + deny: ["bash", "write"], + }, + }, + }) + + await expect(bridge.call("bash", { command: "ls" })).rejects.toThrow(ToolPermissionError) + }) + + test("allows tool not in deny list", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + deny: ["bash"], + }, + }, + }) + + // This will throw ToolNotFoundError in test env (no actual tools), not PermissionError + const result = bridge.call("read", { filePath: "/tmp/test" }) + await expect(result).rejects.not.toThrow(ToolPermissionError) + }) + + test("allow list acts as whitelist", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + allow: ["read", "glob"], + }, + }, + }) + + // bash not in allow list - should be denied + await expect(bridge.call("bash", { command: "ls" })).rejects.toThrow(ToolPermissionError) + }) + + test("skipPermissions bypasses checks", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + deny: ["bash"], + }, + }, + }) + + // With skipPermissions, should not throw PermissionError (may throw ToolNotFoundError) + const result = bridge.call("bash", { command: "ls" }, { skipPermissions: true }) + await expect(result).rejects.not.toThrow(ToolPermissionError) + }) + }) + + describe("list and has", () => { + test("list returns array of tools", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + const tools = await bridge.list() + + expect(Array.isArray(tools)).toBe(true) + // Should have some built-in tools + expect(tools.length).toBeGreaterThan(0) + }, + }) + }) + + test("has returns true for existing tool", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + + // read is a built-in tool + const hasRead = await bridge.has("read") + expect(hasRead).toBe(true) + }, + }) + }) + + test("has returns false for non-existent tool", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + + const hasNonexistent = await bridge.has("this-tool-does-not-exist-12345") + expect(hasNonexistent).toBe(false) + }, + }) + }) + }) + + describe("tool info", () => { + test("list includes tool metadata", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + const tools = await bridge.list() + + const readTool = tools.find((t) => t.id === "read") + expect(readTool).toBeDefined() + expect(readTool?.source).toBe("builtin") + expect(readTool?.description).toBeDefined() + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/provider/inherit.test.ts b/packages/opencode/test/provider/inherit.test.ts new file mode 100644 index 000000000000..864afad16240 --- /dev/null +++ b/packages/opencode/test/provider/inherit.test.ts @@ -0,0 +1,407 @@ +import { test, expect, mock } from "bun:test" +import path from "path" + +// Mock BunProc and default plugins to prevent actual installations during tests +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string, _version?: string) => { + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +const mockPlugin = () => ({}) +mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" +import { Env } from "../../src/env" + +test("inherit creates derived provider with base provider models", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-work"].source).toBe("config") + expect(Object.keys(providers["anthropic-work"].models).length).toBeGreaterThan(0) + expect(providers["anthropic-work"].models["claude-sonnet-4-20250514"]).toBeDefined() + }, + }) +}) + +test("inherit uses custom name when provided", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-work"].name).toBe("Anthropic (Work)") + }, + }) +}) + +test("inherit falls back to base provider name when name not provided", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-work"].name).toBe("Anthropic") + }, + }) +}) + +test("inherited provider models have correct providerID", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-personal": { + inherit: "anthropic", + name: "Anthropic (Personal)", + options: { + apiKey: "personal-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const model = providers["anthropic-personal"].models["claude-sonnet-4-20250514"] + expect(model).toBeDefined() + expect(model.providerID).toBe("anthropic-personal") + }, + }) +}) + +test("getModel works with inherited provider", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = await Provider.getModel("anthropic-work", "claude-sonnet-4-20250514") + expect(model).toBeDefined() + expect(model.providerID).toBe("anthropic-work") + expect(model.id).toBe("claude-sonnet-4-20250514") + }, + }) +}) + +test("inherit skips non-existent base provider with warning", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "nonexistent-derived": { + inherit: "nonexistent-provider", + options: { + apiKey: "test-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["nonexistent-derived"]).toBeUndefined() + }, + }) +}) + +test("inherited provider options merge with config options", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + timeout: 60000, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"].options["timeout"]).toBe(60000) + }, + }) +}) + +test("multiple inherited providers from same base", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + "anthropic-personal": { + inherit: "anthropic", + name: "Anthropic (Personal)", + options: { + apiKey: "personal-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-personal"]).toBeDefined() + expect(providers["anthropic-work"].name).toBe("Anthropic (Work)") + expect(providers["anthropic-personal"].name).toBe("Anthropic (Personal)") + expect(providers["anthropic-work"].models["claude-sonnet-4-20250514"].providerID).toBe("anthropic-work") + expect(providers["anthropic-personal"].models["claude-sonnet-4-20250514"].providerID).toBe("anthropic-personal") + }, + }) +}) + +test("inherited provider respects disabled_providers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + disabled_providers: ["anthropic-work"], + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeUndefined() + }, + }) +}) + +test("inherited provider respects enabled_providers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic-work"], + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + "anthropic-personal": { + inherit: "anthropic", + options: { + apiKey: "personal-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-personal"]).toBeUndefined() + }, + }) +}) + +test("project config can use inherited provider model", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "anthropic-work/claude-sonnet-4-20250514", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const defaultModel = await Provider.defaultModel() + expect(defaultModel.providerID).toBe("anthropic-work") + expect(defaultModel.modelID).toBe("claude-sonnet-4-20250514") + }, + }) +}) + +test("inherited provider coexists with base provider when base has env key", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("ANTHROPIC_API_KEY", "default-api-key") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic"]).toBeDefined() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic"].name).toBe("Anthropic") + expect(providers["anthropic-work"].name).toBe("Anthropic (Work)") + }, + }) +}) diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 000000000000..2d96ebd73b17 --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,307 @@ +# @opencode-ai/plugin + +TypeScript SDK for building OpenCode plugins with full access to tools and MCP servers. + +## Installation + +```bash +bun add @opencode-ai/plugin +``` + +## Quick Start + +```typescript +import { type Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + myTool: { + description: "A custom tool", + args: {}, + async execute() { + // Call built-in tools + const result = await ctx.tools.call("read", { filePath: "/etc/hosts" }) + return result.output as string + }, + }, + }, + } +} + +export default MyPlugin +``` + +## Plugin Context + +Every plugin receives a context object with these properties: + +| Property | Type | Description | +| ----------- | ---------------- | ----------------------------- | +| `client` | `OpencodeClient` | SDK client for API calls | +| `project` | `Project` | Current project info | +| `directory` | `string` | Project root directory | +| `worktree` | `string` | Git worktree path | +| `serverUrl` | `URL` | OpenCode server URL | +| `$` | `BunShell` | Shell for running commands | +| `tools` | `ToolsBridge` | Call any registered tool | +| `mcp` | `McpBridge` | Manage MCP server connections | + +## Tools Bridge + +The `tools` bridge lets plugins call any tool: built-in, MCP, or from other plugins. + +### Calling Tools + +```typescript +// Call a built-in tool +const files = await ctx.tools.call("glob", { pattern: "**/*.ts" }) + +// Call with options +const result = await ctx.tools.call("bash", { command: "ls -la" }, { timeout: 5000, skipPermissions: true }) + +// Access result +console.log(result.output) +console.log(result.title) +console.log(result.metadata) +``` + +### Listing Tools + +```typescript +// Get all available tools +const tools = await ctx.tools.list() + +for (const tool of tools) { + console.log(`${tool.id} (${tool.source}): ${tool.description}`) +} + +// Check if a tool exists +if (await ctx.tools.has("my-mcp-server_some-tool")) { + // Tool is available +} +``` + +### Tool Sources + +Tools come from three sources: + +- `builtin` - Core OpenCode tools (read, write, bash, glob, etc.) +- `mcp` - Tools from connected MCP servers +- `plugin` - Tools defined by plugins + +## MCP Bridge + +The `mcp` bridge provides full control over MCP server connections. + +### Adding Servers + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +// Local server (stdio) +await ctx.mcp.addServer( + "my-tools", + mcpServer.local({ + command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + environment: { DEBUG: "true" }, + timeout: 30000, + }), +) + +// Remote server (SSE) +await ctx.mcp.addServer( + "remote-tools", + mcpServer.remote({ + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + }), +) +``` + +### Server Management + +```typescript +// Check server status +const status = await ctx.mcp.status() +// { "my-tools": { status: "connected" }, ... } + +// Connect/disconnect +await ctx.mcp.connect("my-tools") +await ctx.mcp.disconnect("my-tools") + +// Remove server +await ctx.mcp.removeServer("my-tools") +``` + +### Using MCP Features + +```typescript +// List tools from a specific server +const tools = await ctx.mcp.tools("my-tools") + +// Read a resource +const content = await ctx.mcp.readResource("my-tools", "file:///path/to/file") + +// Get a prompt +const prompt = await ctx.mcp.getPrompt("my-tools", "code-review", { + language: "typescript", +}) +``` + +## Helper Functions + +### mcpTool + +Wrap an MCP tool as a plugin tool with optional transforms: + +```typescript +import { mcpTool } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + // Expose MCP tool directly + echo: mcpTool(ctx, { + server: "my-tools", + tool: "echo", + description: "Echo a message", + }), + + // Transform args and results + search: mcpTool(ctx, { + server: "search-server", + tool: "search", + description: "Search with preprocessing", + transformArgs: (args) => ({ ...args, limit: 10 }), + transformResult: (result) => JSON.stringify(result, null, 2), + }), + }, + } +} +``` + +### mcpServer + +Factory functions for server configurations: + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +// Local server with command array +const local = mcpServer.local({ + command: ["python", "-m", "my_server"], + environment: { API_KEY: "xxx" }, + timeout: 60000, +}) + +// Remote server with OAuth +const remote = mcpServer.remote({ + url: "https://api.example.com/mcp", + oauth: { + clientId: "my-client", + scope: "read write", + }, +}) +``` + +## Error Handling + +The plugin SDK provides typed errors for common failure cases: + +```typescript +import { + ToolNotFoundError, + ToolPermissionError, + ToolCycleError, + McpNotConnectedError, + McpTimeoutError, + McpAuthError, +} from "@opencode-ai/plugin" + +try { + await ctx.tools.call("unknown-tool", {}) +} catch (e) { + if (e instanceof ToolNotFoundError) { + console.log(`Tool ${e.toolId} not found`) + console.log(`Available: ${e.availableTools.join(", ")}`) + } +} + +try { + await ctx.mcp.tools("disconnected-server") +} catch (e) { + if (e instanceof McpNotConnectedError) { + console.log(`Server ${e.server} is not connected`) + } +} +``` + +### Error Types + +| Error | When Thrown | +| ---------------------- | ------------------------------------ | +| `ToolNotFoundError` | Tool ID doesn't exist | +| `ToolPermissionError` | Plugin lacks permission to call tool | +| `ToolCycleError` | Circular tool call detected | +| `McpNotConnectedError` | MCP server not connected | +| `McpTimeoutError` | MCP call exceeded timeout | +| `McpAuthError` | MCP server requires authentication | + +## Permissions + +Plugins can be restricted to specific tools via configuration: + +```json +{ + "plugins": { + "my-plugin": { + "permissions": { + "tools": { + "allow": ["read", "glob", "grep"], + "deny": ["bash", "write"] + } + } + } + } +} +``` + +- `allow` acts as a whitelist (only these tools permitted) +- `deny` acts as a blacklist (these tools blocked) +- `deny` takes precedence over `allow` + +## Hooks + +Plugins can define hooks to respond to events: + +```typescript +export const MyPlugin: Plugin = async (ctx) => { + return { + // Define custom tools + tool: { ... }, + + // React to events + event: async ({ event }) => { + if (event.type === "session.created") { + const hasRead = await ctx.tools.has("read") + console.log(`Read tool available: ${hasRead}`) + } + }, + + // Modify chat behavior + "chat.message": async (input, output) => { + // Transform messages before sending + }, + + // Add auth methods + auth: { ... }, + } +} +``` + +See the main OpenCode documentation for the full list of available hooks. + +## Examples + +See `examples/plugin-mcp-demo/` for a complete working example. diff --git a/packages/plugin/src/errors.ts b/packages/plugin/src/errors.ts new file mode 100644 index 000000000000..923a5bb573f6 --- /dev/null +++ b/packages/plugin/src/errors.ts @@ -0,0 +1,53 @@ +export class ToolNotFoundError extends Error { + override name = "ToolNotFoundError" as const + constructor( + public toolId: string, + public availableTools: string[], + ) { + super( + `Tool "${toolId}" not found. Available: ${availableTools.slice(0, 5).join(", ")}${availableTools.length > 5 ? "..." : ""}`, + ) + } +} + +export class ToolPermissionError extends Error { + override name = "ToolPermissionError" as const + constructor( + public toolId: string, + public pluginName: string, + ) { + super(`Plugin "${pluginName}" does not have permission to call "${toolId}"`) + } +} + +export class ToolCycleError extends Error { + override name = "ToolCycleError" as const + constructor(public callStack: string[]) { + super(`Circular tool call detected: ${callStack.join(" -> ")}`) + } +} + +export class McpNotConnectedError extends Error { + override name = "McpNotConnectedError" as const + constructor(public server: string) { + super(`MCP server "${server}" is not connected`) + } +} + +export class McpTimeoutError extends Error { + override name = "McpTimeoutError" as const + constructor( + public server: string, + public tool: string, + public elapsed: number, + ) { + super(`MCP call to ${server}/${tool} timed out after ${elapsed}ms`) + } +} + +export class McpAuthError extends Error { + override name = "McpAuthError" as const + constructor(public server: string) { + super(`MCP server "${server}" requires authentication. Run: opencode mcp ${server} auth`) + } +} diff --git a/packages/plugin/src/helpers.ts b/packages/plugin/src/helpers.ts new file mode 100644 index 000000000000..3dd281560f2e --- /dev/null +++ b/packages/plugin/src/helpers.ts @@ -0,0 +1,40 @@ +import type { ToolDefinition, ToolContext } from "./tool" +import type { PluginInput } from "./index" +import type { McpServerConfig, McpOAuth } from "./mcp" + +export function mcpTool( + ctx: PluginInput, + config: { + server: string + tool: string + description?: string + transformArgs?: (args: unknown) => unknown + transformResult?: (result: unknown) => unknown + }, +): ToolDefinition { + return { + description: config.description ?? `Call ${config.server}/${config.tool}`, + args: {}, + async execute(args: unknown, context: ToolContext) { + const transformedArgs = config.transformArgs ? config.transformArgs(args) : args + const result = await ctx.tools.call(`${config.server}_${config.tool}`, transformedArgs as Record) + return config.transformResult ? String(config.transformResult(result.output)) : String(result.output) + }, + } +} + +export const mcpServer = { + local(config: { command: string[]; environment?: Record; timeout?: number }): McpServerConfig { + return { + type: "local", + ...config, + } + }, + + remote(config: { url: string; headers?: Record; oauth?: McpOAuth | false }): McpServerConfig { + return { + type: "remote", + ...config, + } + }, +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 86e7ae93420f..cc104accddf8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -14,8 +14,14 @@ import type { import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" +import type { ToolsBridge } from "./tools" +import type { McpBridge } from "./mcp" export * from "./tool" +export * from "./errors" +export * from "./tools" +export * from "./mcp" +export * from "./helpers" export type ProviderContext = { source: "env" | "config" | "custom" | "api" @@ -30,6 +36,8 @@ export type PluginInput = { worktree: string serverUrl: URL $: BunShell + tools: ToolsBridge + mcp: McpBridge } export type Plugin = (input: PluginInput) => Promise diff --git a/packages/plugin/src/mcp.ts b/packages/plugin/src/mcp.ts new file mode 100644 index 000000000000..f60354c140fd --- /dev/null +++ b/packages/plugin/src/mcp.ts @@ -0,0 +1,57 @@ +import type { ToolInfo } from "./tools" + +export interface McpBridge { + addServer(name: string, config: McpServerConfig): Promise + removeServer(name: string): Promise + status(): Promise> + connect(name: string): Promise + disconnect(name: string): Promise + tools(server: string): Promise + readResource(server: string, uri: string): Promise + getPrompt(server: string, name: string, args?: Record): Promise +} + +export type McpServerConfig = + | { + type: "local" + command: string[] + environment?: Record + timeout?: number + } + | { + type: "remote" + url: string + headers?: Record + oauth?: McpOAuth | false + } + +export interface McpOAuth { + clientId?: string + clientSecret?: string + scope?: string +} + +export type McpStatus = + | { status: "connected" } + | { status: "disabled" } + | { status: "failed"; error: string } + | { status: "needs_auth" } + | { status: "needs_client_registration"; error: string } + +export interface ResourceContent { + uri: string + mimeType?: string + text?: string + blob?: string +} + +export interface PromptResult { + description?: string + messages: Array<{ + role: "user" | "assistant" + content: + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + | { type: "resource"; resource: ResourceContent } + }> +} diff --git a/packages/plugin/src/tools.ts b/packages/plugin/src/tools.ts new file mode 100644 index 000000000000..91f004bab103 --- /dev/null +++ b/packages/plugin/src/tools.ts @@ -0,0 +1,31 @@ +import type { JsonSchema7Type } from "zod-to-json-schema" + +export const DEFAULT_TOOL_TIMEOUT = 30000 + +export interface ToolsBridge { + call(id: string, args: Record, options?: ToolCallOptions): Promise> + + list(): Promise + + has(id: string): Promise +} + +export interface ToolCallOptions { + signal?: AbortSignal + timeout?: number + skipPermissions?: boolean +} + +export interface ToolResult { + output: T + title?: string + metadata?: Record +} + +export interface ToolInfo { + id: string + description: string + parameters: JsonSchema7Type + source: "builtin" | "mcp" | "plugin" + server?: string +} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cb2f586775a1..ac5689c99749 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1493,6 +1493,10 @@ export type ProviderConfig = { } } } + /** + * Base provider ID to inherit models from. The inherited provider's models will be cloned under this provider's ID with custom credentials. + */ + inherit?: string whitelist?: Array blacklist?: Array options?: { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 1474cb91558c..8dea993ab950 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -262,6 +262,34 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo --- +#### Provider Inheritance + +Use the `inherit` field to create multiple configurations of the same provider with different credentials. This is useful for managing work and personal accounts separately. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic-work": { + "inherit": "anthropic", + "name": "Anthropic (Work)", + "options": { + "apiKey": "{env:ANTHROPIC_WORK_KEY}" + } + } + } +} +``` + +- `inherit` - The base provider ID to inherit models from +- `name` - Display name for the provider in the model picker + +The inherited provider will have all models from the base provider available under the new provider ID (e.g., `anthropic-work/claude-sonnet-4-20250514`). + +[Learn more about provider inheritance](/docs/providers#provider-inheritance). + +--- + #### Provider-Specific Options Some providers support additional configuration options beyond the generic `timeout` and `apiKey` settings. diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 2a8039452881..fb9f3c438b5b 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1740,6 +1740,81 @@ Some useful routing options: --- +## Provider Inheritance + +You can create multiple configurations of the same provider using the `inherit` field. This is useful when you need to use the same provider with different credentials, such as separate work and personal accounts. + +### Multiple Accounts + +To use multiple accounts for the same provider (e.g., two Anthropic API keys for work and personal use): + +```json title="~/.config/opencode/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic-work": { + "inherit": "anthropic", + "name": "Anthropic (Work)", + "options": { + "apiKey": "{env:ANTHROPIC_WORK_KEY}" + } + }, + "anthropic-personal": { + "inherit": "anthropic", + "name": "Anthropic (Personal)", + "options": { + "apiKey": "{env:ANTHROPIC_PERSONAL_KEY}" + } + } + } +} +``` + +This creates two new providers (`anthropic-work` and `anthropic-personal`) that inherit all models from the base `anthropic` provider but use different API keys. + +### Per-Project Selection + +Once you have multiple provider configurations, you can select which one to use in each project: + +```json title="~/work/project/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic-work/claude-sonnet-4-20250514" +} +``` + +```json title="~/personal/project/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic-personal/claude-sonnet-4-20250514" +} +``` + +### How It Works + +When you use `inherit`: + +1. **Models are cloned** - All models from the base provider are available under the new provider ID +2. **Custom name** - Use the `name` field to set a display name for the model picker +3. **Options merge** - The `options` you specify (like `apiKey`) are applied to the inherited provider +4. **Independent provider** - The inherited provider appears as a separate entry in the model picker + +:::tip +Both the base provider and inherited providers can coexist. If you have `ANTHROPIC_API_KEY` set and also define `anthropic-work`, you'll see both in the model picker. +::: + +### Inherit Options + +| Option | Description | +|--------|-------------| +| `inherit` | The provider ID to inherit from (e.g., `"anthropic"`, `"openai"`) | +| `name` | Display name for the provider in the model picker | +| `options.apiKey` | API key for this provider configuration | +| `options.baseURL` | Custom base URL (optional) | +| `options.timeout` | Request timeout in milliseconds (optional) | + +--- + ## Custom provider To add any **OpenAI-compatible** provider that's not listed in the `/connect` command: