From 48f8b5de9877ede03b10bed4f52d8da264eb8c06 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 18:46:51 +0530 Subject: [PATCH] feat(plugin): add integration registration --- packages/core/src/plugin/host.ts | 169 ++++++++---------- packages/core/src/plugin/promise.ts | 85 +++++---- packages/core/src/plugin/search/exa.ts | 18 +- packages/core/src/plugin/search/parallel.ts | 18 +- packages/core/test/plugin/host.ts | 159 ++++++++-------- packages/core/test/plugin/promise.test.ts | 16 +- packages/core/test/plugin/search.test.ts | 24 ++- packages/plugin/src/v2/effect/index.ts | 5 +- packages/plugin/src/v2/effect/integration.ts | 33 ++-- packages/plugin/src/v2/promise/index.ts | 6 +- packages/plugin/src/v2/promise/integration.ts | 57 ++++-- packages/sdk-next/test/embedded.test.ts | 15 +- 12 files changed, 342 insertions(+), 263 deletions(-) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index d98e1fd039..b280eaa54e 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,7 @@ export * as PluginHost from "./host" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -192,6 +193,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, ), }, + register: (definition) => + integration.transform((draft) => { + registerIntegration(draft, definition) + }), transform: (callback) => integration.transform((draft) => { callback({ @@ -201,98 +206,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int remove: (id) => draft.remove(Integration.ID.make(id)), method: { list: (id) => mutable(draft.method.list(Integration.ID.make(id))), - update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( - Effect.map((authorization) => { - if (authorization.mode === "auto") { - return { - ...authorization, - callback: authorization.callback.pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - } - return { - ...authorization, - callback: (code: string) => - authorization.callback(code).pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - }), - ), - ...(refresh - ? { - refresh: (value: Credential.OAuth) => - refresh(value).pipe( - Effect.map((next) => - Credential.OAuth.make({ - ...next, - methodID: Integration.MethodID.make(next.methodID), - }), - ), - ), - } - : {}), - ...(input.label ? { label: input.label } : {}), - }) - return - } - if (input.method.type === "env") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "env", names: input.method.names }, - }) - return - } - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "key", label: input.method.label }, - }) - }, + update: (input) => draft.method.update(methodImplementation(input)), remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), }, - capability: { - search: { - list: () => - draft.capability.search.list().map((provider) => ({ - integrationID: provider.integrationID, - capability: provider.capability, - execute: (input, context) => - provider.execute(input, { - ...context, - credential: context.credential - ? Schema.decodeUnknownSync(Credential.Value)(context.credential) - : undefined, - }), - })), - update: (input) => - draft.capability.search.update({ - integrationID: Integration.ID.make(input.integrationID), - capability: input.capability, - execute: input.execute, - }), - remove: (id) => draft.capability.search.remove(Integration.ID.make(id)), - }, - }, }) }), }, @@ -401,3 +318,75 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, } satisfies PluginContext }) + +function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { + const integrationID = Integration.ID.make(definition.id) + draft.update(integrationID, (integration) => (integration.name = definition.name)) + for (const method of definition.methods ?? []) { + if (method.type === "env") { + draft.method.update(methodImplementation({ integrationID: definition.id, method })) + continue + } + if (method.type === "key") { + draft.method.update(methodImplementation({ integrationID: definition.id, method })) + continue + } + const { authorize, refresh, credentialLabel, ...info } = method + draft.method.update( + methodImplementation({ + integrationID: definition.id, + method: info, + authorize, + ...(refresh ? { refresh } : {}), + ...(credentialLabel ? { label: credentialLabel } : {}), + }), + ) + } + if (!definition.search) return + draft.capability.search.update({ + integrationID, + capability: { type: "search", connection: definition.search.connection }, + execute: definition.search.execute, + }) +} + +function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation { + if ("authorize" in input) { + const refresh = input.refresh + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe(Effect.map(credential)), + } + } + return { + ...authorization, + callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)), + } + }), + ), + ...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}), + ...(input.label ? { label: input.label } : {}), + } + } + if (input.method.type === "env") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + } + } + return { + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + } +} + +function credential(value: CredentialOAuth) { + return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) +} diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index aaf8ea95ad..65967969dc 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,7 +1,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import type { IntegrationDefinition, Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } @@ -85,36 +85,8 @@ export function fromPromise(plugin: Plugin) { attemptStatus: (input) => run(host.integration.attemptStatus(input)), attemptComplete: (input) => run(host.integration.attemptComplete(input)), attemptCancel: (input) => run(host.integration.attemptCancel(input)), - transform: (callback) => - register( - host.integration.transform((draft) => { - callback({ - ...draft, - capability: { - search: { - list: () => - draft.capability.search.list().map((provider) => ({ - integrationID: provider.integrationID, - capability: provider.capability, - execute: (input, execution) => - Effect.runPromiseWith(context)(provider.execute(input, execution)), - })), - update: (input) => - draft.capability.search.update({ - integrationID: input.integrationID, - capability: input.capability, - execute: (query, execution) => - Effect.tryPromise({ - try: (signal) => input.execute(query, { ...execution, signal }), - catch: (cause) => cause, - }), - }), - remove: draft.capability.search.remove, - }, - }, - }) - }), - ), + register: (definition) => register(host.integration.register(adaptIntegration(definition))), + transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)), @@ -147,3 +119,54 @@ export function fromPromise(plugin: Plugin) { }), }) } + +function adaptIntegration(definition: IntegrationDefinition) { + const { methods, search, ...info } = definition + return { + ...info, + methods: methods?.map((method) => { + if (method.type !== "oauth") return method + const { authorize, refresh, ...info } = method + return { + ...info, + authorize: (inputs: Parameters[0]) => + Effect.tryPromise({ try: () => authorize(inputs), catch: (cause) => cause }).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: Effect.tryPromise({ try: () => authorization.callback, catch: (cause) => cause }), + } + } + return { + ...authorization, + callback: (code: string) => + Effect.tryPromise({ try: () => authorization.callback(code), catch: (cause) => cause }), + } + }), + ), + ...(refresh + ? { + refresh: (credential: Parameters[0]) => + Effect.tryPromise({ try: () => refresh(credential), catch: (cause) => cause }), + } + : {}), + } + }), + ...(search + ? { + search: { + connection: search.connection, + execute: ( + input: Parameters[0], + execution: Omit[1], "signal">, + ) => + Effect.tryPromise({ + try: (signal) => search.execute(input, { ...execution, signal }), + catch: (cause) => cause, + }), + }, + } + : {}), + } +} diff --git a/packages/core/src/plugin/search/exa.ts b/packages/core/src/plugin/search/exa.ts index d1fc44bfdd..4d0f46dc68 100644 --- a/packages/core/src/plugin/search/exa.ts +++ b/packages/core/src/plugin/search/exa.ts @@ -19,13 +19,15 @@ export const Plugin = define({ id: "opencode.search.exa", effect: Effect.fn("SearchExa.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((draft) => { - draft.update("exa", (integration) => (integration.name = "Exa")) - draft.method.update({ integrationID: "exa", method: { type: "key", label: "API key (optional)" } }) - draft.method.update({ integrationID: "exa", method: { type: "env", names: ["EXA_API_KEY"] } }) - draft.capability.search.update({ - integrationID: "exa", - capability: { type: "search", connection: "optional" }, + yield* ctx.integration.register({ + id: "exa", + name: "Exa", + methods: [ + { type: "key", label: "API key (optional)" }, + { type: "env", names: ["EXA_API_KEY"] }, + ], + search: { + connection: "optional", execute: (input, context) => { const url = new URL(endpoint) if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key) @@ -37,7 +39,7 @@ export const Plugin = define({ contextMaxCharacters: input.contextMaxCharacters, }).pipe(Effect.map((text) => ({ text: text ?? "" }))) }, - }) + }, }) }), }) diff --git a/packages/core/src/plugin/search/parallel.ts b/packages/core/src/plugin/search/parallel.ts index 75a9ab7c38..ccaaecaee1 100644 --- a/packages/core/src/plugin/search/parallel.ts +++ b/packages/core/src/plugin/search/parallel.ts @@ -18,13 +18,15 @@ export const Plugin = define({ id: "opencode.search.parallel", effect: Effect.fn("SearchParallel.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((draft) => { - draft.update("parallel", (integration) => (integration.name = "Parallel")) - draft.method.update({ integrationID: "parallel", method: { type: "key", label: "API key (optional)" } }) - draft.method.update({ integrationID: "parallel", method: { type: "env", names: ["PARALLEL_API_KEY"] } }) - draft.capability.search.update({ - integrationID: "parallel", - capability: { type: "search", connection: "optional" }, + yield* ctx.integration.register({ + id: "parallel", + name: "Parallel", + methods: [ + { type: "key", label: "API key (optional)" }, + { type: "env", names: ["PARALLEL_API_KEY"] }, + ], + search: { + connection: "optional", execute: (input, context) => SearchMcp.call( http, @@ -41,7 +43,7 @@ export const Plugin = define({ ...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}), }, ).pipe(Effect.map((text) => ({ text: text ?? "" }))), - }) + }, }) }), }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 6c0840da41..3d43c9b064 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,11 +1,16 @@ -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" +import type { + CredentialOAuth, + IntegrationEnvMethod, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" type Overrides = Partial> @@ -51,6 +56,7 @@ export function host(overrides: Overrides = {}): PluginContext { attemptStatus: () => Effect.die("unused integration.attemptStatus"), attemptComplete: () => Effect.die("unused integration.attemptComplete"), attemptCancel: () => Effect.die("unused integration.attemptCancel"), + register: () => Effect.die("unused integration.register"), transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -203,6 +209,10 @@ export function integrationHost(integration: Integration.Interface): PluginConte connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, ), }, + register: (definition) => + integration.transform((draft) => { + registerIntegration(draft, definition) + }), transform: (callback) => integration.transform((draft) => callback({ @@ -215,91 +225,80 @@ export function integrationHost(integration: Integration.Interface): PluginConte remove: (id) => draft.remove(Integration.ID.make(id)), method: { list: (id) => draft.method.list(Integration.ID.make(id)).map(method), - update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( - Effect.map((authorization) => { - if (authorization.mode === "auto") { - return { - ...authorization, - callback: authorization.callback.pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - } - return { - ...authorization, - callback: (code: string) => - authorization.callback(code).pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - }), - ), - ...(refresh - ? { - refresh: (value: Credential.OAuth) => - refresh(value).pipe( - Effect.map((next) => - Credential.OAuth.make({ - ...next, - methodID: Integration.MethodID.make(next.methodID), - }), - ), - ), - } - : {}), - ...(input.label ? { label: input.label } : {}), - }) - return - } - if (input.method.type === "env") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, names: [...input.method.names] }, - }) - return - } - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: input.method, - }) - }, + update: (input) => draft.method.update(methodImplementation(input)), remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), }, - capability: { - search: { - list: () => [], - update: (input) => - draft.capability.search.update({ - integrationID: Integration.ID.make(input.integrationID), - capability: input.capability, - execute: input.execute, - }), - remove: (id) => draft.capability.search.remove(Integration.ID.make(id)), - }, - }, }), ), } } +function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { + const integrationID = Integration.ID.make(definition.id) + draft.update(integrationID, (integration) => (integration.name = definition.name)) + for (const item of definition.methods ?? []) { + if (item.type === "env") { + draft.method.update(methodImplementation({ integrationID: definition.id, method: item })) + continue + } + if (item.type === "key") { + draft.method.update(methodImplementation({ integrationID: definition.id, method: item })) + continue + } + const { authorize, refresh, credentialLabel, ...method } = item + draft.method.update( + methodImplementation({ + integrationID: definition.id, + method, + authorize, + ...(refresh ? { refresh } : {}), + ...(credentialLabel ? { label: credentialLabel } : {}), + }), + ) + } + if (!definition.search) return + draft.capability.search.update({ + integrationID, + capability: { type: "search", connection: definition.search.connection }, + execute: definition.search.execute, + }) +} + +function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation { + if ("authorize" in input) { + const refresh = input.refresh + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { ...authorization, callback: authorization.callback.pipe(Effect.map(oauthCredential)) } + } + return { + ...authorization, + callback: (code: string) => authorization.callback(code).pipe(Effect.map(oauthCredential)), + } + }), + ), + ...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(oauthCredential)) } : {}), + ...(input.label ? { label: input.label } : {}), + } + } + if (input.method.type === "env") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + } + } + return { integrationID: Integration.ID.make(input.integrationID), method: input.method } +} + +function oauthCredential(value: CredentialOAuth) { + return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) +} + function method(value: Integration.Method) { if (value.type === "env") return { type: value.type, names: [...value.names] } if (value.type === "key") return { type: value.type, label: value.label } diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index f14bab47e0..a4be50a50e 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -103,17 +103,23 @@ describe("fromPromise", () => { const promisePlugin = define({ id: "promise-search", setup: async (ctx) => { - await ctx.integration.transform((draft) => { - draft.capability.search.update({ - integrationID: "promise-search", - capability: { type: "search", connection: "optional" }, + await ctx.integration.register({ + id: "promise-search", + name: "Promise Search", + methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }], + search: { + connection: "optional", execute: async (input) => ({ text: `promise: ${input.query}` }), - }) + }, }) }, }) yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect(yield* integrations.get(Integration.ID.make("promise-search"))).toMatchObject({ + name: "Promise Search", + methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }], + }) const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search")) if (!provider) return yield* Effect.die("Expected promise search provider") expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" }) diff --git a/packages/core/test/plugin/search.test.ts b/packages/core/test/plugin/search.test.ts index d390ab067d..656faeef98 100644 --- a/packages/core/test/plugin/search.test.ts +++ b/packages/core/test/plugin/search.test.ts @@ -20,6 +20,29 @@ beforeEach(() => { const it = searchIntegrationTest describe("built-in search integrations", () => { + it.effect("registers and disposes an atomic search integration", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const registration = yield* integrationHost(integrations).register({ + id: "test-search", + name: "Test Search", + methods: [{ type: "key", label: "API key" }], + search: { + connection: "required", + execute: (input) => Effect.succeed({ text: input.query }), + }, + }) + + expect(yield* integrations.get(Integration.ID.make("test-search"))).toMatchObject({ + name: "Test Search", + methods: [{ type: "key", label: "API key" }], + capabilities: [{ type: "search", connection: "required" }], + }) + yield* registration.dispose + expect(yield* integrations.get(Integration.ID.make("test-search"))).toBeUndefined() + }), + ) + it.effect("registers Exa and maps search hints to its MCP tool", () => Effect.gen(function* () { const integrations = yield* Integration.Service @@ -105,5 +128,4 @@ describe("built-in search integrations", () => { expect(JSON.stringify(output)).not.toContain("parallel-secret") }), ) - }) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 80434b8702..75a87dae1c 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -8,9 +8,12 @@ export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" export type { IntegrationDraft, + IntegrationDefinition, IntegrationHooks, + IntegrationMethodDefinition, IntegrationMethodRegistration, - IntegrationSearchCapabilityRegistration, + IntegrationOAuthMethodDefinition, + IntegrationSearchDefinition, } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 0665b39b2d..30c9d98fb1 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -12,7 +12,7 @@ import type { import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Search } from "@opencode-ai/schema/search" import type { Effect, Scope } from "effect" -import type { TransformHook } from "./registration.js" +import type { Registration, TransformHook } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -45,18 +45,29 @@ export type IntegrationMethodRegistration = readonly method: IntegrationEnvMethod } -export interface IntegrationSearchCapabilityRegistration { - readonly integrationID: string - readonly capability: { - readonly type: "search" - readonly connection: "optional" | "required" - } +export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & { + readonly authorize: (inputs: IntegrationInputs) => Effect.Effect + readonly refresh?: (credential: CredentialOAuth) => Effect.Effect + readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined +} + +export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationSearchDefinition { + readonly connection: "optional" | "required" readonly execute: ( input: Search.Input, context: { readonly credential?: CredentialValue; readonly sessionID?: string }, ) => Effect.Effect } +export interface IntegrationDefinition { + readonly id: string + readonly name: string + readonly methods?: readonly IntegrationMethodDefinition[] + readonly search?: IntegrationSearchDefinition +} + export interface IntegrationDraft { list(): readonly IntegrationRef[] get(id: string): IntegrationRef | undefined @@ -67,16 +78,10 @@ export interface IntegrationDraft { update(input: IntegrationMethodRegistration): void remove(integrationID: string, method: IntegrationMethod): void } - readonly capability: { - readonly search: { - list(): readonly IntegrationSearchCapabilityRegistration[] - update(input: IntegrationSearchCapabilityRegistration): void - remove(integrationID: string): void - } - } } export interface IntegrationHooks extends IntegrationApi { + readonly register: (definition: IntegrationDefinition) => Effect.Effect readonly transform: TransformHook readonly reload: () => Effect.Effect readonly connection: { diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index e3a1a62e43..7575d1a266 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -9,9 +9,13 @@ export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" export type { IntegrationDraft, + IntegrationDefinition, IntegrationHooks, + IntegrationMethodDefinition, IntegrationMethodRegistration, - IntegrationSearchCapabilityRegistration, + IntegrationOAuthAuthorization, + IntegrationOAuthMethodDefinition, + IntegrationSearchDefinition, } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SessionHooks } from "./runtime.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 67e8209817..4016442cc7 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,34 +1,59 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationMethodRegistration } from "../effect/integration.js" -import type { CredentialValue } from "@opencode-ai/sdk/v2/types" +import type { + CredentialOAuth, + CredentialValue, + IntegrationEnvMethod, + IntegrationInputs, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import type { Search } from "@opencode-ai/schema/search" -import type { TransformHook } from "./registration.js" +import type { Registration, TransformHook } from "./registration.js" export type { IntegrationMethodRegistration } -export interface IntegrationSearchCapabilityRegistration { - readonly integrationID: string - readonly capability: { - readonly type: "search" - readonly connection: "optional" | "required" - } +export type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Promise + } + | { + readonly mode: "code" + readonly callback: (code: string) => Promise + } +) + +export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & { + readonly authorize: (inputs: IntegrationInputs) => Promise + readonly refresh?: (credential: CredentialOAuth) => Promise + readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined +} + +export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationSearchDefinition { + readonly connection: "optional" | "required" readonly execute: ( input: Search.Input, context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal }, ) => Promise } -export interface IntegrationDraft extends Omit { - readonly capability: { - readonly search: { - list(): readonly IntegrationSearchCapabilityRegistration[] - update(input: IntegrationSearchCapabilityRegistration): void - remove(integrationID: string): void - } - } +export interface IntegrationDefinition { + readonly id: string + readonly name: string + readonly methods?: readonly IntegrationMethodDefinition[] + readonly search?: IntegrationSearchDefinition } +export type IntegrationDraft = import("../effect/integration.js").IntegrationDraft + export interface IntegrationHooks extends IntegrationApi { + readonly register: (definition: IntegrationDefinition) => Promise readonly transform: TransformHook readonly reload: () => Promise readonly connection: { diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 6c34cc8e90..5aa44ccfc1 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -266,14 +266,13 @@ it.live("embedded client exposes integration-backed search", () => yield* opencode.plugin({ id: `embedded-search-${crypto.randomUUID()}`, effect: (ctx) => - ctx.integration.transform((draft) => { - draft.update(providerID, (integration) => (integration.name = "Embedded search")) - draft.capability.search.update({ - integrationID: providerID, - capability: { type: "search", connection: "optional" }, - execute: (input) => - Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), - }) + ctx.integration.register({ + id: providerID, + name: "Embedded search", + search: { + connection: "optional", + execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), + }, }), })