feat(plugin): add integration registration

This commit is contained in:
Shoubhit Dash 2026-07-07 18:46:51 +05:30
commit 48f8b5de98
12 changed files with 342 additions and 263 deletions

View file

@ -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<Omit<PluginContext, "options">>
@ -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 }

View file

@ -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" })

View file

@ -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")
}),
)
})