Procházet zdrojové kódy

feat(core): add opencode integration (#33555)

Dax před 2 měsíci
rodič
revize
cf80b5c470

+ 2 - 2
packages/core/src/credential.ts

@@ -20,13 +20,13 @@ export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
   refresh: Schema.String,
   access: Schema.String,
   expires: NonNegativeInt,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
 }) {}
 
 export class Key extends Schema.Class<Key>("Credential.Key")({
   type: Schema.Literal("key"),
   key: Schema.String,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
 }) {}
 
 export const Value = Schema.Union([OAuth, Key])

+ 56 - 16
packages/core/src/integration.ts

@@ -90,7 +90,9 @@ export const EnvMethod = Schema.Struct({
 }).annotate({ identifier: "Integration.EnvMethod" })
 export type EnvMethod = typeof EnvMethod.Type
 
-export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
+export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod])
+  .pipe(Schema.toTaggedUnion("type"))
+  .annotate({ identifier: "Integration.Method" })
 export type Method = typeof Method.Type
 
 export class Info extends Schema.Class<Info>("Integration.Info")({
@@ -100,7 +102,8 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
   connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
 }) {}
 
-export type Inputs = Readonly<{ [key: string]: string }>
+export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
+export type Inputs = typeof Inputs.Type
 
 export type OAuthAuthorization = {
   readonly url: string
@@ -108,11 +111,11 @@ export type OAuthAuthorization = {
 } & (
   | {
       readonly mode: "auto"
-      readonly callback: Effect.Effect<Credential.Value, unknown>
+      readonly callback: Effect.Effect<Credential.OAuth, unknown>
     }
   | {
       readonly mode: "code"
-      readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
+      readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
     }
 )
 
@@ -121,6 +124,7 @@ export interface OAuthImplementation {
   readonly method: OAuthMethod
   readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
   readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
+  readonly label?: (credential: Credential.OAuth) => string | undefined
 }
 
 export interface KeyImplementation {
@@ -178,12 +182,17 @@ export const Event = {
     type: "integration.updated",
     schema: {},
   }),
+  ConnectionUpdated: EventV2.define({
+    type: "integration.connection.updated",
+    schema: { integrationID: ID },
+  }),
 }
 
-export type Ref = {
-  id: ID
-  name: string
-}
+export const Ref = Schema.Struct({
+  id: ID,
+  name: Schema.String,
+}).annotate({ identifier: "Integration.Ref" })
+export type Ref = typeof Ref.Type
 
 type Entry = {
   ref: Types.DeepMutable<Ref>
@@ -215,7 +224,11 @@ export interface Interface extends State.Transformable<Draft> {
   readonly list: () => Effect.Effect<Info[]>
   readonly connection: {
     /** Returns the active connection for one integration. */
-    readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
+    readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
+    /** Resolves a connection into usable credential material. */
+    readonly resolve: (
+      connection: IntegrationConnection.Info,
+    ) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
     /** Runs a key method and stores the resulting credential. */
     readonly key: (input: {
       /** Integration receiving the credential. */
@@ -385,7 +398,7 @@ export const locationLayer = Layer.effect(
       return error instanceof Error ? error.message : String(error)
     }
 
-    const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
+    const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
       const now = yield* Clock.currentTimeMillis
       const result = yield* SynchronizedRef.modify(attempts, (current) => {
         const attempt = current.get(attemptID)
@@ -397,14 +410,13 @@ export const locationLayer = Layer.effect(
       })
       if (!result) return
       if (Exit.isSuccess(exit)) {
+        const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
         yield* credentials.create({
           integrationID: result.integrationID,
-          label: result.label,
-          value:
-            exit.value.type === "oauth"
-              ? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
-              : exit.value,
+          label: result.label ?? implementation?.label?.(exit.value),
+          value: exit.value,
         })
+        yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
         yield* events.publish(Event.Updated, {})
       }
       yield* close(result.scope)
@@ -445,10 +457,29 @@ export const locationLayer = Layer.effect(
         ).toSorted((a, b) => a.name.localeCompare(b.name))
       }),
       connection: {
-        forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
+        active: Effect.fn("Integration.connection.active")(function* (id) {
           const entry = state.get().integrations.get(id)
           return resolveConnections(entry, yield* credentials.list(id))[0]
         }),
+        resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
+          if (connection.type === "env") {
+            const key = process.env[connection.name]
+            return key ? new Credential.Key({ type: "key", key }) : undefined
+          }
+          const credential = yield* credentials.get(connection.id)
+          if (!credential) return undefined
+          if (credential.value.type === "key") return credential.value
+          const implementation = state
+            .get()
+            .integrations.get(credential.integrationID)
+            ?.implementations.get(credential.value.methodID)
+          if (!implementation?.refresh) return credential.value
+          const now = yield* Clock.currentTimeMillis
+          if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
+          const value = yield* authorize(implementation.refresh(credential.value))
+          yield* credentials.update(credential.id, { value })
+          return value
+        }),
         key: Effect.fn("Integration.connection.key")(function* (input) {
           const method = state
             .get()
@@ -460,6 +491,7 @@ export const locationLayer = Layer.effect(
             label: input.label,
             value: new Credential.Key({ type: "key", key: input.key }),
           })
+          yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
           yield* events.publish(Event.Updated, {})
         }),
         oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
@@ -503,11 +535,19 @@ export const locationLayer = Layer.effect(
           })
         }),
         update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
+          const credential = yield* credentials.get(credentialID)
           yield* credentials.update(credentialID, updates)
+          if (credential) {
+            yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
+          }
           yield* events.publish(Event.Updated, {})
         }),
         remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
+          const credential = yield* credentials.get(credentialID)
           yield* credentials.remove(credentialID)
+          if (credential) {
+            yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
+          }
           yield* events.publish(Event.Updated, {})
         }),
       },

+ 64 - 0
packages/core/src/plugin/host.ts

@@ -6,6 +6,7 @@ import { AgentV2 } from "../agent"
 import { AISDK } from "../aisdk"
 import { Catalog } from "../catalog"
 import { CommandV2 } from "../command"
+import { Credential } from "../credential"
 import { Integration } from "../integration"
 import { ModelV2 } from "../model"
 import { PluginV2 } from "../plugin"
@@ -97,6 +98,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
     },
     integration: {
       reload: integration.reload,
+      connection: {
+        active: (id) => integration.connection.active(Integration.ID.make(id)),
+        resolve: (connection) =>
+          integration.connection.resolve(
+            connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
+          ),
+      },
       transform: (callback) =>
         integration.transform((draft) =>
           callback({
@@ -107,6 +115,62 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
             method: {
               list: (id) => 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) =>
+                                    new Credential.OAuth({
+                                      ...credential,
+                                      methodID: Integration.MethodID.make(credential.methodID),
+                                    }),
+                                ),
+                              ),
+                            }
+                          }
+                          return {
+                            ...authorization,
+                            callback: (code: string) =>
+                              authorization.callback(code).pipe(
+                                Effect.map(
+                                  (credential) =>
+                                    new Credential.OAuth({
+                                      ...credential,
+                                      methodID: Integration.MethodID.make(credential.methodID),
+                                    }),
+                                ),
+                              ),
+                          }
+                        }),
+                      ),
+                    ...(refresh
+                      ? {
+                          refresh: (value: Credential.OAuth) =>
+                            refresh(value).pipe(
+                              Effect.map(
+                                (next) =>
+                                  new Credential.OAuth({
+                                    ...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),

+ 5 - 0
packages/core/src/plugin/internal.ts

@@ -23,6 +23,7 @@ import { Npm } from "../npm"
 import { PluginV2 } from "../plugin"
 import { Reference } from "../reference"
 import { SkillV2 } from "../skill"
+import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { AgentPlugin } from "./agent"
 import { CommandPlugin } from "./command"
 import { ModelsDevPlugin } from "./models-dev"
@@ -38,6 +39,7 @@ export type Requirements =
   | FileSystem.Service
   | FSUtil.Service
   | Global.Service
+  | HttpClient.HttpClient
   | Integration.Service
   | Location.Service
   | ModelsDev.Service
@@ -69,6 +71,7 @@ export const locationLayer = Layer.effectDiscard(
     const fs = yield* FSUtil.Service
     const filesystem = yield* FileSystem.Service
     const global = yield* Global.Service
+    const http = yield* HttpClient.HttpClient
     const skill = yield* SkillV2.Service
     const reference = yield* Reference.Service
     const add = <R>(input: Plugin<R>) => {
@@ -90,6 +93,7 @@ export const locationLayer = Layer.effectDiscard(
               Effect.provideService(FSUtil.Service, fs),
               Effect.provideService(FileSystem.Service, filesystem),
               Effect.provideService(Global.Service, global),
+              Effect.provideService(HttpClient.HttpClient, http),
               Effect.provideService(SkillV2.Service, skill),
               Effect.provideService(Reference.Service, reference),
             ),
@@ -115,4 +119,5 @@ export const locationLayer = Layer.effectDiscard(
   Layer.provideMerge(PluginV2.locationLayer),
   Layer.provideMerge(Config.locationLayer),
   Layer.provideMerge(FileSystem.locationLayer),
+  Layer.provideMerge(FetchHttpClient.layer),
 )

+ 5 - 0
packages/core/src/plugin/promise.ts

@@ -65,6 +65,11 @@ export function fromPromise(plugin: Plugin) {
           integration: {
             transform: transform(host.integration),
             reload: () => run(host.integration.reload()),
+            connection: {
+              active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
+              resolve: (connection) =>
+                Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
+            },
           },
           plugin: {
             add: (input) => {

+ 0 - 257
packages/core/src/plugin/provider/openai-auth.ts

@@ -1,257 +0,0 @@
-import { createServer } from "node:http"
-import { Deferred, Effect } from "effect"
-import { Integration } from "../../integration"
-import { Credential } from "../../credential"
-import { InstallationVersion } from "../../installation/version"
-
-const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
-const issuer = "https://auth.openai.com"
-const callbackPort = 1455
-const pollingSafetyMargin = 3000
-
-type Pkce = {
-  verifier: string
-  challenge: string
-}
-
-type TokenResponse = {
-  id_token: string
-  access_token: string
-  refresh_token: string
-  expires_in?: number
-}
-
-type Claims = {
-  chatgpt_account_id?: string
-  organizations?: Array<{ id: string }>
-  "https://api.openai.com/auth"?: { chatgpt_account_id?: string }
-}
-
-const browserMethodID = Integration.MethodID.make("chatgpt-browser")
-const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
-
-export const browser = {
-  integrationID: Integration.ID.make("openai"),
-  method: {
-    id: browserMethodID,
-    type: "oauth",
-    label: "ChatGPT Pro/Plus (browser)",
-  },
-  authorize: () =>
-    Effect.gen(function* () {
-      const pkce = yield* Effect.promise(generatePKCE)
-      const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
-      const code = yield* Deferred.make<string, Error>()
-      const redirect = `http://localhost:${callbackPort}/auth/callback`
-      const server = createServer((request, response) => {
-        const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
-        if (url.pathname !== "/auth/callback") {
-          response.writeHead(404).end("Not found")
-          return
-        }
-        const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
-        const value = url.searchParams.get("code")
-        if (error) {
-          Effect.runFork(Deferred.fail(code, new Error(error)))
-          response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
-          return
-        }
-        if (!value || url.searchParams.get("state") !== state) {
-          const message = value ? "Invalid OAuth state" : "Missing authorization code"
-          Effect.runFork(Deferred.fail(code, new Error(message)))
-          response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
-          return
-        }
-        Effect.runFork(Deferred.succeed(code, value))
-        response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
-      })
-      yield* Effect.callback<void, Error>((resume) => {
-        server.once("error", (error) => resume(Effect.fail(error)))
-        server.listen(callbackPort, "localhost", () => resume(Effect.void))
-      })
-      yield* Effect.addFinalizer(() =>
-        Effect.sync(() => {
-          server.close()
-        }),
-      )
-      return {
-        mode: "auto" as const,
-        url: authorizeURL(redirect, pkce, state),
-        instructions: "Complete authorization in your browser. This window will close automatically.",
-        callback: Deferred.await(code).pipe(
-          Effect.flatMap((value) => exchange(value, redirect, pkce)),
-          Effect.map((tokens) => credential(browserMethodID, tokens)),
-        ),
-      }
-    }),
-  refresh: (value) => refresh(value),
-} satisfies Integration.OAuthImplementation
-
-export const headless = {
-  integrationID: Integration.ID.make("openai"),
-  method: {
-    id: headlessMethodID,
-    type: "oauth",
-    label: "ChatGPT Pro/Plus (headless)",
-  },
-  authorize: () =>
-    Effect.gen(function* () {
-      const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
-        `${issuer}/api/accounts/deviceauth/usercode`,
-        {
-          method: "POST",
-          headers: headers("application/json"),
-          body: JSON.stringify({ client_id: clientID }),
-        },
-      )
-      const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
-      return {
-        mode: "auto" as const,
-        url: `${issuer}/codex/device`,
-        instructions: `Enter code: ${device.user_code}`,
-        callback: Effect.gen(function* () {
-          while (true) {
-            const response = yield* Effect.tryPromise({
-              try: (signal) =>
-                fetch(`${issuer}/api/accounts/deviceauth/token`, {
-                  method: "POST",
-                  headers: headers("application/json"),
-                  body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
-                  signal,
-                }),
-              catch: (cause) => cause,
-            })
-            if (response.ok) {
-              const data = (yield* Effect.promise(() => response.json())) as {
-                authorization_code: string
-                code_verifier: string
-              }
-              return credential(
-                headlessMethodID,
-                yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
-                  verifier: data.code_verifier,
-                  challenge: "",
-                }),
-              )
-            }
-            if (response.status !== 403 && response.status !== 404) {
-              return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
-            }
-            yield* Effect.sleep(interval + pollingSafetyMargin)
-          }
-        }),
-      }
-    }),
-  refresh: (value) => refresh(value),
-} satisfies Integration.OAuthImplementation
-
-function headers(contentType: string) {
-  return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
-}
-
-function exchange(code: string, redirect: string, pkce: Pkce) {
-  return request<TokenResponse>(`${issuer}/oauth/token`, {
-    method: "POST",
-    headers: headers("application/x-www-form-urlencoded"),
-    body: new URLSearchParams({
-      grant_type: "authorization_code",
-      code,
-      redirect_uri: redirect,
-      client_id: clientID,
-      code_verifier: pkce.verifier,
-    }).toString(),
-  })
-}
-
-function refresh(value: Credential.OAuth) {
-  return request<TokenResponse>(`${issuer}/oauth/token`, {
-    method: "POST",
-    headers: headers("application/x-www-form-urlencoded"),
-    body: new URLSearchParams({
-      grant_type: "refresh_token",
-      refresh_token: value.refresh,
-      client_id: clientID,
-    }).toString(),
-  }).pipe(
-    Effect.map((tokens) => {
-      const next = credential(value.methodID, tokens)
-      return new Credential.OAuth({
-        ...next,
-        metadata: next.metadata ?? value.metadata,
-      })
-    }),
-  )
-}
-
-function request<A>(url: string, init: RequestInit) {
-  return Effect.tryPromise({
-    try: async (signal) => {
-      const response = await fetch(url, { ...init, signal })
-      if (!response.ok) throw new Error(`Request failed: ${response.status}`)
-      return response.json() as Promise<A>
-    },
-    catch: (cause) => cause,
-  })
-}
-
-function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
-  const accountID = extractAccountID(tokens)
-  return new Credential.OAuth({
-    type: "oauth",
-    methodID,
-    refresh: tokens.refresh_token,
-    access: tokens.access_token,
-    expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
-    metadata: accountID ? { accountID } : undefined,
-  })
-}
-
-async function generatePKCE(): Promise<Pkce> {
-  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
-  const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
-  const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
-  return { verifier, challenge }
-}
-
-function base64UrlEncode(buffer: ArrayBuffer) {
-  return Buffer.from(buffer).toString("base64url")
-}
-
-function authorizeURL(redirect: string, pkce: Pkce, state: string) {
-  return `${issuer}/oauth/authorize?${new URLSearchParams({
-    response_type: "code",
-    client_id: clientID,
-    redirect_uri: redirect,
-    scope: "openid profile email offline_access",
-    code_challenge: pkce.challenge,
-    code_challenge_method: "S256",
-    id_token_add_organizations: "true",
-    codex_cli_simplified_flow: "true",
-    state,
-    originator: "opencode",
-  })}`
-}
-
-function extractAccountID(tokens: TokenResponse) {
-  return claim(tokens.id_token) ?? claim(tokens.access_token)
-}
-
-function claim(token: string) {
-  const part = token.split(".")[1]
-  if (!part) return
-  try {
-    const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
-    return (
-      claims.chatgpt_account_id ??
-      claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
-      claims.organizations?.[0]?.id
-    )
-  } catch {
-    return
-  }
-}
-
-const successPage =
-  "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
-const errorPage = (message: string) =>
-  `<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`

+ 255 - 7
packages/core/src/plugin/provider/openai.ts

@@ -1,15 +1,155 @@
-import { Effect } from "effect"
+import { createServer } from "node:http"
+import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
+import { define } from "@opencode-ai/plugin/v2/effect/plugin"
+import { Deferred, Effect } from "effect"
+import type { Scope } from "effect"
+import { Credential } from "../../credential"
+import { InstallationVersion } from "../../installation/version"
+import { Integration } from "../../integration"
 import { ModelV2 } from "../../model"
-import { define } from "../internal"
 import { ProviderV2 } from "../../provider"
-import { Integration } from "../../integration"
-import { browser, headless } from "./openai-auth"
+import type { PluginInternal } from "../internal"
+
+const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
+const issuer = "https://auth.openai.com"
+const callbackPort = 1455
+const pollingSafetyMargin = 3000
+const browserMethodID = Integration.MethodID.make("chatgpt-browser")
+const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
+
+type Pkce = {
+  verifier: string
+  challenge: string
+}
+
+type TokenResponse = {
+  id_token: string
+  access_token: string
+  refresh_token: string
+  expires_in?: number
+}
+
+type Claims = {
+  chatgpt_account_id?: string
+  organizations?: Array<{ id: string }>
+  "https://api.openai.com/auth"?: { chatgpt_account_id?: string }
+}
+
+const browser = {
+  integrationID: Integration.ID.make("openai"),
+  method: {
+    id: browserMethodID,
+    type: "oauth",
+    label: "ChatGPT Pro/Plus (browser)",
+  },
+  authorize: () =>
+    Effect.gen(function* () {
+      const pkce = yield* Effect.promise(generatePKCE)
+      const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
+      const code = yield* Deferred.make<string, Error>()
+      const redirect = `http://localhost:${callbackPort}/auth/callback`
+      const server = createServer((request, response) => {
+        const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
+        if (url.pathname !== "/auth/callback") {
+          response.writeHead(404).end("Not found")
+          return
+        }
+        const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
+        const value = url.searchParams.get("code")
+        if (error) {
+          Effect.runFork(Deferred.fail(code, new Error(error)))
+          response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
+          return
+        }
+        if (!value || url.searchParams.get("state") !== state) {
+          const message = value ? "Invalid OAuth state" : "Missing authorization code"
+          Effect.runFork(Deferred.fail(code, new Error(message)))
+          response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
+          return
+        }
+        Effect.runFork(Deferred.succeed(code, value))
+        response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
+      })
+      yield* Effect.callback<void, Error>((resume) => {
+        server.once("error", (error) => resume(Effect.fail(error)))
+        server.listen(callbackPort, "localhost", () => resume(Effect.void))
+      })
+      yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
+      return {
+        mode: "auto" as const,
+        url: authorizeURL(redirect, pkce, state),
+        instructions: "Complete authorization in your browser. This window will close automatically.",
+        callback: Deferred.await(code).pipe(
+          Effect.flatMap((value) => exchange(value, redirect, pkce)),
+          Effect.map((tokens) => credential(browserMethodID, tokens)),
+        ),
+      }
+    }),
+  refresh: (value) => refresh(browserMethodID, value),
+} satisfies IntegrationOAuthMethodRegistration
+
+const headless = {
+  integrationID: Integration.ID.make("openai"),
+  method: {
+    id: headlessMethodID,
+    type: "oauth",
+    label: "ChatGPT Pro/Plus (headless)",
+  },
+  authorize: () =>
+    Effect.gen(function* () {
+      const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
+        `${issuer}/api/accounts/deviceauth/usercode`,
+        {
+          method: "POST",
+          headers: headers("application/json"),
+          body: JSON.stringify({ client_id: clientID }),
+        },
+      )
+      const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
+      return {
+        mode: "auto" as const,
+        url: `${issuer}/codex/device`,
+        instructions: `Enter code: ${device.user_code}`,
+        callback: Effect.gen(function* () {
+          while (true) {
+            const response = yield* Effect.tryPromise({
+              try: (signal) =>
+                fetch(`${issuer}/api/accounts/deviceauth/token`, {
+                  method: "POST",
+                  headers: headers("application/json"),
+                  body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
+                  signal,
+                }),
+              catch: (cause) => cause,
+            })
+            if (response.ok) {
+              const data = (yield* Effect.promise(() => response.json())) as {
+                authorization_code: string
+                code_verifier: string
+              }
+              return credential(
+                headlessMethodID,
+                yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
+                  verifier: data.code_verifier,
+                  challenge: "",
+                }),
+              )
+            }
+            if (response.status !== 403 && response.status !== 404) {
+              return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
+            }
+            yield* Effect.sleep(interval + pollingSafetyMargin)
+          }
+        }),
+      }
+    }),
+  refresh: (value) => refresh(headlessMethodID, value),
+} satisfies IntegrationOAuthMethodRegistration
 
 export const OpenAIPlugin = define({
   id: "openai",
   effect: Effect.fn(function* (ctx) {
-    const integrations = yield* Integration.Service
-    yield* integrations.transform((draft) => {
+    yield* ctx.integration.transform((draft) => {
       draft.method.update(browser)
       draft.method.update(headless)
     })
@@ -41,4 +181,112 @@ export const OpenAIPlugin = define({
       }),
     )
   }),
-})
+} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
+
+function headers(contentType: string) {
+  return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
+}
+
+function exchange(code: string, redirect: string, pkce: Pkce) {
+  return request<TokenResponse>(`${issuer}/oauth/token`, {
+    method: "POST",
+    headers: headers("application/x-www-form-urlencoded"),
+    body: new URLSearchParams({
+      grant_type: "authorization_code",
+      code,
+      redirect_uri: redirect,
+      client_id: clientID,
+      code_verifier: pkce.verifier,
+    }).toString(),
+  })
+}
+
+function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
+  return request<TokenResponse>(`${issuer}/oauth/token`, {
+    method: "POST",
+    headers: headers("application/x-www-form-urlencoded"),
+    body: new URLSearchParams({
+      grant_type: "refresh_token",
+      refresh_token: value.refresh,
+      client_id: clientID,
+    }).toString(),
+  }).pipe(
+    Effect.map((tokens) => {
+      const next = credential(methodID, tokens)
+      return new Credential.OAuth({ ...next, metadata: next.metadata ?? value.metadata })
+    }),
+  )
+}
+
+function request<A>(url: string, init: RequestInit) {
+  return Effect.tryPromise({
+    try: async (signal) => {
+      const response = await fetch(url, { ...init, signal })
+      if (!response.ok) throw new Error(`Request failed: ${response.status}`)
+      return response.json() as Promise<A>
+    },
+    catch: (cause) => cause,
+  })
+}
+
+function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
+  const accountID = extractAccountID(tokens)
+  return new Credential.OAuth({
+    type: "oauth",
+    methodID,
+    refresh: tokens.refresh_token,
+    access: tokens.access_token,
+    expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
+    metadata: accountID ? { accountID } : undefined,
+  })
+}
+
+async function generatePKCE(): Promise<Pkce> {
+  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
+  const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
+  const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
+  return { verifier, challenge }
+}
+
+function base64UrlEncode(buffer: ArrayBuffer) {
+  return Buffer.from(buffer).toString("base64url")
+}
+
+function authorizeURL(redirect: string, pkce: Pkce, state: string) {
+  return `${issuer}/oauth/authorize?${new URLSearchParams({
+    response_type: "code",
+    client_id: clientID,
+    redirect_uri: redirect,
+    scope: "openid profile email offline_access",
+    code_challenge: pkce.challenge,
+    code_challenge_method: "S256",
+    id_token_add_organizations: "true",
+    codex_cli_simplified_flow: "true",
+    state,
+    originator: "opencode",
+  })}`
+}
+
+function extractAccountID(tokens: TokenResponse) {
+  return claim(tokens.id_token) ?? claim(tokens.access_token)
+}
+
+function claim(token: string) {
+  const part = token.split(".")[1]
+  if (!part) return
+  try {
+    const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
+    return (
+      claims.chatgpt_account_id ??
+      claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
+      claims.organizations?.[0]?.id
+    )
+  } catch {
+    return
+  }
+}
+
+const successPage =
+  "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
+const errorPage = (message: string) =>
+  `<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`

+ 333 - 22
packages/core/src/plugin/provider/opencode.ts

@@ -1,32 +1,343 @@
-import { Effect } from "effect"
-import { define } from "../internal"
-import { ProviderV2 } from "../../provider"
+import { Duration, Effect, Schema, Stream } from "effect"
+import type { Scope } from "effect"
+import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
+import { define } from "@opencode-ai/plugin/v2/effect/plugin"
+import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { EventV2 } from "../../event"
+import { Credential } from "../../credential"
 import { Integration } from "../../integration"
+import { ModelV2 } from "../../model"
+import { ModelRequest } from "../../model-request"
+import { ProviderV2 } from "../../provider"
+
+const defaultServer = "https://console.opencode.ai"
+const clientID = "opencode-cli"
+const methodID = Integration.MethodID.make("device")
+const RemoteRequest = Schema.Struct({
+  headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+  body: Schema.Record(Schema.String, Schema.Any).pipe(Schema.optional),
+})
+const RemoteModelApi = Schema.Union([
+  Schema.Struct({ id: ModelV2.ID.pipe(Schema.optional), ...ProviderV2.AISDK.fields }),
+  Schema.Struct({ id: ModelV2.ID.pipe(Schema.optional), ...ProviderV2.Native.fields }),
+  Schema.Struct({ id: ModelV2.ID }),
+])
+const RemoteCost = Schema.Struct({
+  tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Int }).pipe(Schema.optional),
+  input: Schema.Finite,
+  output: Schema.Finite,
+  cache: Schema.Struct({
+    read: Schema.Finite.pipe(Schema.optional),
+    write: Schema.Finite.pipe(Schema.optional),
+  }).pipe(Schema.optional),
+})
+const RemoteModel = Schema.Struct({
+  family: ModelV2.Family.pipe(Schema.optional),
+  name: Schema.String.pipe(Schema.optional),
+  api: RemoteModelApi.pipe(Schema.optional),
+  capabilities: ModelV2.Capabilities.pipe(Schema.optional),
+  request: Schema.Struct({ ...RemoteRequest.fields, variant: Schema.String.pipe(Schema.optional) }).pipe(
+    Schema.optional,
+  ),
+  variants: Schema.Struct({
+    id: ModelV2.VariantID,
+    ...RemoteRequest.fields,
+  }).pipe(Schema.Array, Schema.optional),
+  cost: Schema.Union([RemoteCost, Schema.Array(RemoteCost)]).pipe(Schema.optional),
+  disabled: Schema.Boolean.pipe(Schema.optional),
+  limit: Schema.Struct({
+    context: Schema.Int.pipe(Schema.optional),
+    input: Schema.Int.pipe(Schema.optional),
+    output: Schema.Int.pipe(Schema.optional),
+  }).pipe(Schema.optional),
+})
+const RemoteProvider = Schema.Struct({
+  name: Schema.String.pipe(Schema.optional),
+  api: ProviderV2.Api.pipe(Schema.optional),
+  request: RemoteRequest.pipe(Schema.optional),
+  models: Schema.Record(Schema.String, RemoteModel).pipe(Schema.optional),
+})
+const RemoteConfig = Schema.Struct({
+  providers: Schema.Record(Schema.String, RemoteProvider),
+})
+const RemoteResponse = Schema.Struct({ config: RemoteConfig })
+const Device = Schema.Struct({
+  device_code: Schema.String,
+  user_code: Schema.String,
+  verification_uri_complete: Schema.String,
+  expires_in: Schema.Number,
+  interval: Schema.Number,
+})
+const Token = Schema.Struct({
+  access_token: Schema.String,
+  refresh_token: Schema.String,
+  expires_in: Schema.Number,
+})
+const TokenPending = Schema.Struct({ error: Schema.String })
+const DeviceToken = Schema.Union([Token, TokenPending])
+const User = Schema.Struct({ id: Schema.String, email: Schema.String })
+const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
 
-export const OpencodePlugin = define({
+function oauth(http: HttpClient.HttpClient) {
+  return {
+    integrationID: Integration.ID.make("opencode"),
+    method: {
+      id: methodID,
+      type: "oauth",
+      label: "Sign in with OpenCode",
+      prompts: [
+        {
+          type: "text",
+          key: "server",
+          message: "OpenCode server",
+          placeholder: defaultServer,
+        },
+      ],
+    },
+    authorize: (inputs) =>
+      Effect.gen(function* () {
+        const url = new URL(inputs.server || defaultServer)
+        const server = `${url.origin}${url.pathname.replace(/\/+$/, "")}`
+        const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
+        return {
+          mode: "auto" as const,
+          url: `${server}${device.verification_uri_complete}`,
+          instructions: `Enter code: ${device.user_code}`,
+          callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
+        }
+      }),
+    refresh: (credential) =>
+      Effect.gen(function* () {
+        const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
+        const token = yield* post(
+          http,
+          `${server}/auth/device/token`,
+          { grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
+          Token,
+        )
+        return {
+          ...credential,
+          access: token.access_token,
+          refresh: token.refresh_token,
+          expires: Date.now() + token.expires_in * 1000,
+        }
+      }),
+    label: (credential) => {
+      return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
+    },
+  } satisfies IntegrationOAuthMethodRegistration
+}
+
+export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
   id: "opencode",
   effect: Effect.fn(function* (ctx) {
-    const integrations = yield* Integration.Service
-    let hasKey = false
-    yield* ctx.catalog.transform(
-      Effect.fn(function* (evt) {
-        const item = evt.provider.get(ProviderV2.ID.opencode)
-        if (!item) return
-        const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
-        hasKey = Boolean(
-          process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
-        )
-        evt.provider.update(item.provider.id, (provider) => {
-          if (!hasKey) provider.request.body.apiKey = "public"
+    const events = yield* EventV2.Service
+    const http = yield* HttpClient.HttpClient
+    let connected = false
+    let providers: typeof RemoteConfig.Type.providers | undefined
+
+    const load = Effect.fn("OpencodePlugin.load")(function* () {
+      const connection = yield* ctx.integration.connection.active("opencode")
+      const credential = connection
+        ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
+        : undefined
+      connected = connection !== undefined
+      providers = credential
+        ? yield* fetchProviders(http, credential).pipe(
+            Effect.catch((cause) =>
+              Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
+            ),
+          )
+        : undefined
+    })
+
+    yield* ctx.integration.transform((draft) => {
+      draft.update("opencode", (integration) => {
+        integration.name = "OpenCode"
+      })
+      draft.method.update(oauth(http))
+      draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key" } })
+    })
+
+    yield* load()
+    yield* ctx.catalog.transform((catalog) => {
+      for (const [providerID, item] of Object.entries(providers ?? {})) {
+        catalog.provider.update(providerID, (provider) => {
+          if (item.name !== undefined) provider.name = item.name
+          if (item.api !== undefined) provider.api = { ...item.api }
+          if (item.request !== undefined) {
+            Object.assign(provider.request.headers, item.request.headers)
+            Object.assign(provider.request.body, item.request.body)
+          }
         })
-        if (hasKey) return
-        for (const model of item.models.values()) {
-          if (!model.cost.some((cost) => cost.input > 0)) continue
-          evt.model.update(item.provider.id, model.id, (draft) => {
-            draft.enabled = false
+        const providerApi = catalog.provider.get(providerID)?.provider.api
+        const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
+
+        for (const [modelID, config] of Object.entries(item.models ?? {})) {
+          catalog.model.update(providerID, modelID, (model) => {
+            if (config.family !== undefined) model.family = config.family
+            if (config.name !== undefined) model.name = config.name
+            if (config.api !== undefined) model.api = { ...model.api, ...config.api }
+            const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
+            if (config.capabilities !== undefined) {
+              model.capabilities = {
+                tools: config.capabilities.tools,
+                input: [...config.capabilities.input],
+                output: [...config.capabilities.output],
+              }
+            }
+            if (config.request !== undefined) {
+              ModelRequest.assign(model.request, {
+                headers: config.request.headers,
+                ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
+              })
+              if (config.request.variant !== undefined) model.request.variant = config.request.variant
+            }
+            if (config.variants !== undefined) {
+              for (const variant of config.variants) {
+                let existing = model.variants.find((item) => item.id === variant.id)
+                if (!existing) {
+                  existing = { id: variant.id, headers: {}, body: {}, generation: {}, options: {} }
+                  model.variants.push(existing)
+                }
+                ModelRequest.assign(existing, {
+                  headers: variant.headers,
+                  ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
+                })
+              }
+            }
+            if (config.cost !== undefined) {
+              model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
+                tier: cost.tier && { ...cost.tier },
+                input: cost.input,
+                output: cost.output,
+                cache: { read: cost.cache?.read ?? 0, write: cost.cache?.write ?? 0 },
+              }))
+            }
+            if (config.disabled !== undefined) model.enabled = !config.disabled
+            if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
           })
         }
-      }),
+      }
+
+      const item = catalog.provider.get(ProviderV2.ID.opencode)
+      if (!item) return
+      const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey)
+      catalog.provider.update(item.provider.id, (provider) => {
+        if (!hasKey) provider.request.body.apiKey = "public"
+      })
+      if (hasKey) return
+      for (const model of item.models.values()) {
+        if (!model.cost.some((cost) => cost.input > 0)) continue
+        catalog.model.update(item.provider.id, model.id, (draft) => {
+          draft.enabled = false
+        })
+      }
+    })
+
+    yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
+      Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
+      Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
+      Effect.forkScoped({ startImmediately: true }),
     )
   }),
 })
+
+function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
+  const metadata = value.metadata
+  const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
+  const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
+  const token = value.type === "oauth" ? value.access : value.key
+  return http
+    .execute(
+      HttpClientRequest.get(`${server}/api/config`).pipe(
+        HttpClientRequest.acceptJson,
+        HttpClientRequest.bearerToken(token),
+        HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
+      ),
+    )
+    .pipe(
+      Effect.flatMap((response) => {
+        if (response.status === 404) return Effect.succeed(undefined)
+        return HttpClientResponse.filterStatusOk(response).pipe(
+          Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
+          Effect.map((remote) => remote.config.providers),
+        )
+      }),
+    )
+}
+
+function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
+  const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
+    Effect.gen(function* () {
+      yield* Effect.sleep(wait)
+      const result = yield* post(
+        http,
+        `${server}/auth/device/token`,
+        {
+          grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+          device_code: deviceCode,
+          client_id: clientID,
+        },
+        DeviceToken,
+        false,
+      )
+      if ("access_token" in result) return yield* credential(http, server, result)
+      if (result.error === "authorization_pending") return yield* loop(wait)
+      if (result.error === "slow_down") {
+        return yield* loop(Duration.sum(wait, Duration.seconds(5)))
+      }
+      return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`))
+    })
+  return loop(interval)
+}
+
+function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) {
+  return Effect.gen(function* () {
+    const [user, orgs] = yield* Effect.all(
+      [
+        get(http, `${server}/api/user`, token.access_token, User),
+        get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)),
+      ],
+      { concurrency: 2 },
+    )
+    const org = orgs.toSorted((a, b) => a.id.localeCompare(b.id))[0]
+    return new Credential.OAuth({
+      type: "oauth" as const,
+      methodID,
+      access: token.access_token,
+      refresh: token.refresh_token,
+      expires: Date.now() + token.expires_in * 1000,
+      metadata: {
+        server,
+        accountID: user.id,
+        email: user.email,
+        orgID: org?.id,
+        orgName: org?.name,
+      },
+    })
+  })
+}
+
+function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, token: string, schema: S) {
+  return HttpClient.filterStatusOk(http)
+    .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token)))
+    .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)))
+}
+
+function post<S extends Schema.Top>(
+  http: HttpClient.HttpClient,
+  url: string,
+  body: Record<string, string>,
+  schema: S,
+  statusOk = true,
+) {
+  return HttpClientRequest.post(url).pipe(
+    HttpClientRequest.acceptJson,
+    HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
+    Effect.flatMap((request) => http.execute(request)),
+    Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
+    Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
+  )
+}

+ 1 - 1
packages/core/src/session/runner/model.ts

@@ -210,7 +210,7 @@ export const locationLayer = Layer.effect(
             modelID: session.model.id,
           })
         if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
-        const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
+        const connection = yield* integrations.connection.active(Integration.ID.make(selected.providerID))
         return yield* resolve(
           session,
           selected,

+ 1 - 0
packages/core/src/skill.ts

@@ -27,6 +27,7 @@ export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.Embedd
 
 export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
   Schema.toTaggedUnion("type"),
+  Schema.annotate({ identifier: "SkillV2.Source" }),
   withStatics(() => ({
     equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
       if (a.type !== b.type) return false

+ 1 - 1
packages/core/test/integration.test.ts

@@ -332,7 +332,7 @@ describe("Integration", () => {
             },
             { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
           ])
-          expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
+          expect(yield* integrations.connection.active(integrationID)).toEqual({
             type: "credential",
             id: personal.id,
             label: "Personal",

+ 2 - 0
packages/core/test/plugin/fixture.ts

@@ -9,6 +9,7 @@ import { RepositoryCache } from "@opencode-ai/core/repository-cache"
 import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
 import { Effect, Layer } from "effect"
+import { FetchHttpClient } from "effect/unstable/http"
 import { tempLocationLayer } from "../fixture/location"
 
 export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
@@ -16,6 +17,7 @@ export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2
     Layer.mergeAll(
       Credential.defaultLayer,
       EventV2.defaultLayer,
+      FetchHttpClient.layer,
       FSUtil.defaultLayer,
       Global.defaultLayer,
       Layer.succeed(

+ 81 - 10
packages/core/test/plugin/host.ts

@@ -1,6 +1,7 @@
 import type { 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"
@@ -31,6 +32,10 @@ export function host(overrides: Overrides = {}): PluginContext {
     integration: overrides.integration ?? {
       transform: () => Effect.die("unused integration.transform"),
       reload: () => Effect.die("unused integration.reload"),
+      connection: {
+        active: () => Effect.die("unused integration.connection.active"),
+        resolve: () => Effect.die("unused integration.connection.resolve"),
+      },
     },
     plugin: overrides.plugin ?? {
       add: () => Effect.die("unused plugin.add"),
@@ -138,6 +143,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
 export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
   return {
     reload: integration.reload,
+    connection: {
+      active: (id) => integration.connection.active(Integration.ID.make(id)),
+      resolve: (connection) =>
+        integration.connection.resolve(
+          connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
+        ),
+    },
     transform: (callback) =>
       integration.transform((draft) =>
         callback({
@@ -150,16 +162,75 @@ 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) =>
-              input.method.type === "env"
-                ? draft.method.update({
-                    integrationID: Integration.ID.make(input.integrationID),
-                    method: { ...input.method, names: [...input.method.names] },
-                  })
-                : draft.method.update({
-                    integrationID: Integration.ID.make(input.integrationID),
-                    method: input.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) =>
+                                  new Credential.OAuth({
+                                    ...credential,
+                                    methodID: Integration.MethodID.make(credential.methodID),
+                                  }),
+                              ),
+                            ),
+                          }
+                        }
+                        return {
+                          ...authorization,
+                          callback: (code: string) =>
+                            authorization.callback(code).pipe(
+                              Effect.map(
+                                (credential) =>
+                                  new Credential.OAuth({
+                                    ...credential,
+                                    methodID: Integration.MethodID.make(credential.methodID),
+                                  }),
+                              ),
+                            ),
+                        }
+                      }),
+                    ),
+                  ...(refresh
+                    ? {
+                        refresh: (value: Credential.OAuth) =>
+                          refresh(value).pipe(
+                            Effect.map(
+                              (next) =>
+                                new Credential.OAuth({
+                                  ...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,
+              })
+            },
             remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
           },
         }),

+ 74 - 0
packages/core/test/plugin/provider-opencode.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 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 { PluginV2 } from "@opencode-ai/core/plugin"
@@ -48,6 +49,79 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
 const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
 
 describe("OpencodePlugin", () => {
+  it.effect("registers OAuth and API key methods", () =>
+    Effect.gen(function* () {
+      yield* addPlugin()
+      expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
+        {
+          id: Integration.MethodID.make("device"),
+          type: "oauth",
+          label: "Sign in with OpenCode",
+          prompts: [
+            {
+              type: "text",
+              key: "server",
+              message: "OpenCode server",
+              placeholder: "https://console.opencode.ai",
+            },
+          ],
+        },
+        { type: "key", label: "API key" },
+      ])
+    }),
+  )
+
+  it.live("loads providers and models from the connected OpenCode server", () =>
+    Effect.acquireUseRelease(
+      Effect.sync(() => {
+        const authorization: Array<string | null> = []
+        return {
+          authorization,
+          server: Bun.serve({
+            port: 0,
+            fetch: (request) => {
+              authorization.push(request.headers.get("authorization"))
+              return Response.json({
+                config: {
+                  providers: {
+                    remote: {
+                      name: "Remote",
+                      models: {
+                        model: { name: "Remote Model" },
+                      },
+                    },
+                  },
+                },
+              })
+            },
+          }),
+        }
+      }),
+      ({ authorization, server }) =>
+        Effect.gen(function* () {
+          const credentials = yield* Credential.Service
+          yield* credentials.create({
+            integrationID: Integration.ID.make("opencode"),
+            value: new Credential.Key({
+              type: "key",
+              key: "secret",
+              metadata: { server: server.url.origin },
+            }),
+          })
+
+          yield* addPlugin()
+
+          const catalog = yield* Catalog.Service
+          expect((yield* catalog.provider.get(ProviderV2.ID.make("remote")))?.name).toBe("Remote")
+          expect((yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))?.name).toBe(
+            "Remote Model",
+          )
+          expect(authorization).toContain("Bearer secret")
+        }),
+      ({ server }) => Effect.promise(() => server.stop(true)),
+    ),
+  )
+
   it.effect("uses a public key and disables paid models without credentials", () =>
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {

+ 3 - 45
packages/opencode/src/config/config.ts

@@ -13,12 +13,11 @@ import { Env } from "../env"
 import { applyEdits, modify } from "jsonc-parser"
 import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
 import { existsSync } from "fs"
-import { Account } from "@/account/account"
 import { isRecord } from "@/util/record"
 import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { InstanceState } from "@/effect/instance-state"
-import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
+import { Context, Duration, Effect, Exit, Fiber, Layer, Schema } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { containsPath, type InstanceContext } from "../project/instance-context"
@@ -177,7 +176,6 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const authSvc = yield* Auth.Service
-    const accountSvc = yield* Account.Service
     const env = yield* Env.Service
     const npmSvc = yield* Npm.Service
     const http = yield* HttpClient.HttpClient
@@ -316,7 +314,6 @@ export const layer = Layer.effect(
 
         let result: Info = {}
         const authEnv: Record<string, string> = {}
-        const consoleManagedProviders = new Set<string>()
         let activeOrgName: string | undefined
 
         const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
@@ -474,44 +471,6 @@ export const layer = Layer.effect(
           yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
         }
 
-        const activeAccount = Option.getOrUndefined(
-          yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
-        )
-        if (activeAccount?.active_org_id) {
-          const accountID = activeAccount.id
-          const orgID = activeAccount.active_org_id
-          const url = activeAccount.url
-          yield* Effect.gen(function* () {
-            const [configOpt, tokenOpt] = yield* Effect.all(
-              [accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
-              { concurrency: 2 },
-            )
-            if (Option.isSome(tokenOpt)) {
-              process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
-              yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
-            }
-
-            if (Option.isSome(configOpt)) {
-              const source = `${url}/api/config`
-              const next = yield* loadConfig(JSON.stringify(configOpt.value), {
-                dir: path.dirname(source),
-                source,
-              })
-              for (const providerID of Object.keys(next.provider ?? {})) {
-                consoleManagedProviders.add(providerID)
-              }
-              yield* merge(source, next, "global")
-            }
-          }).pipe(
-            Effect.withSpan("Config.loadActiveOrgConfig"),
-            Effect.catch((err) =>
-              Effect.logDebug("failed to fetch remote account config", {
-                error: err instanceof Error ? err.message : String(err),
-              }),
-            ),
-          )
-        }
-
         const managedDir = ConfigManaged.managedConfigDir()
         if (existsSync(managedDir)) {
           for (const file of ["opencode.json", "opencode.jsonc"]) {
@@ -587,7 +546,7 @@ export const layer = Layer.effect(
           directories,
           deps,
           consoleState: {
-            consoleManagedProviders: Array.from(consoleManagedProviders),
+            consoleManagedProviders: [],
             activeOrgName,
             switchableOrgCount: 0,
           },
@@ -676,11 +635,10 @@ export const defaultLayer = layer.pipe(
   Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Env.defaultLayer),
   Layer.provide(Auth.defaultLayer),
-  Layer.provide(Account.defaultLayer),
   Layer.provide(Npm.defaultLayer),
   Layer.provide(FetchHttpClient.layer),
 )
 
-export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])
+export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Env.node, Npm.node, httpClient])
 
 export * as Config from "./config"

+ 13 - 1
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -1,6 +1,9 @@
 import { Schema } from "effect"
 import { HttpApi } from "effect/unstable/httpapi"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Credential } from "@opencode-ai/core/credential"
+import { Integration } from "@opencode-ai/core/integration"
+import { SkillV2 } from "@opencode-ai/core/skill"
 import { InstanceDisposed } from "@/server/event"
 import { Question } from "@/question"
 import { ConfigApi } from "./groups/config"
@@ -72,7 +75,16 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
   .addHttpApi(InstanceHttpApi)
   .addHttpApi(Api)
   .addHttpApi(PtyConnectApi)
-  .annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])
+  .annotate(HttpApi.AdditionalSchemas, [
+    EventSchema,
+    Question.Replied,
+    Question.Rejected,
+    Credential.Value,
+    Integration.Inputs,
+    Integration.Method,
+    Integration.Ref,
+    SkillV2.Source,
+  ])
 
 export type RootHttpApiType = typeof RootHttpApi
 export type InstanceHttpApiType = typeof InstanceHttpApi

+ 1 - 48
packages/opencode/test/config/config.test.ts

@@ -1,6 +1,6 @@
 import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
-import { Cause, Effect, Exit, Layer, Option } from "effect"
+import { Cause, Effect, Exit, Layer } from "effect"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
@@ -13,7 +13,6 @@ import { InstanceRef } from "../../src/effect/instance-ref"
 import type { InstanceContext } from "../../src/project/instance-context"
 import { Auth } from "../../src/auth"
 import { Account } from "../../src/account/account"
-import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Env } from "../../src/env"
 import {
@@ -141,7 +140,6 @@ const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
 // Get managed config directory from environment (set in preload.ts)
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 const originalTestToken = process.env.TEST_TOKEN
-const originalConsoleToken = process.env.OPENCODE_CONSOLE_TOKEN
 
 beforeEach(async () => {
   await clear(true)
@@ -151,8 +149,6 @@ afterEach(async () => {
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
   if (originalTestToken === undefined) delete process.env.TEST_TOKEN
   else process.env.TEST_TOKEN = originalTestToken
-  if (originalConsoleToken === undefined) delete process.env.OPENCODE_CONSOLE_TOKEN
-  else process.env.OPENCODE_CONSOLE_TOKEN = originalConsoleToken
   await clear(true)
 })
 
@@ -564,49 +560,6 @@ it.instance("handles file inclusion with replacement tokens", () =>
   }),
 )
 
-const accountTokenIt = configIt({
-  account: Layer.mock(Account.Service)({
-    active: () =>
-      Effect.succeed(
-        Option.some({
-          id: AccountID.make("account-1"),
-          email: "user@example.com",
-          url: "https://control.example.com",
-          active_org_id: OrgID.make("org-1"),
-        }),
-      ),
-    activeOrg: () =>
-      Effect.succeed(
-        Option.some({
-          account: {
-            id: AccountID.make("account-1"),
-            email: "user@example.com",
-            url: "https://control.example.com",
-            active_org_id: OrgID.make("org-1"),
-          },
-          org: {
-            id: OrgID.make("org-1"),
-            name: "Example Org",
-          },
-        }),
-      ),
-    config: () =>
-      Effect.succeed(
-        Option.some({
-          provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
-        }),
-      ),
-    token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
-  }),
-})
-
-accountTokenIt.instance("resolves env templates in account config with account token", () =>
-  Effect.gen(function* () {
-    const config = yield* Config.use.get()
-    expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
-  }),
-)
-
 it.instance("validates config schema and throws on invalid fields", () =>
   Effect.gen(function* () {
     const test = yield* TestInstance

+ 14 - 0
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -68,6 +68,20 @@ function isBuiltInEndpointError(name: string) {
 }
 
 describe("PublicApi OpenAPI v2 errors", () => {
+  test("includes plugin-facing core schemas", () => {
+    const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
+
+    expect(Object.keys(spec.components.schemas)).toEqual(
+      expect.arrayContaining([
+        "CredentialValue",
+        "IntegrationInputs",
+        "IntegrationMethod",
+        "IntegrationRef",
+        "SkillV2Source",
+      ]),
+    )
+  })
+
   test("documents nested legacy global sync events", () => {
     const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
     const schema = spec.components.schemas.SyncEventSessionCreated

+ 2 - 0
packages/plugin/package.json

@@ -13,6 +13,8 @@
     "./tool": "./src/tool.ts",
     "./tui": "./src/tui.ts",
     "./v2/effect": "./src/v2/effect/index.ts",
+    "./v2/effect/integration": "./src/v2/effect/integration.ts",
+    "./v2/effect/plugin": "./src/v2/effect/plugin.ts",
     "./v2/promise": "./src/v2/promise/index.ts"
   },
   "files": [

+ 39 - 8
packages/plugin/src/v2/effect/integration.ts

@@ -1,13 +1,41 @@
 import type {
+  ConnectionInfo,
+  CredentialOAuth,
+  CredentialValue,
   IntegrationEnvMethod,
-  IntegrationInfo,
+  IntegrationInputs,
   IntegrationKeyMethod,
+  IntegrationMethod,
   IntegrationOAuthMethod,
+  IntegrationRef,
 } from "@opencode-ai/sdk/v2/types"
+import type { Effect, Scope } from "effect"
 import type { Hooks } from "./registration.js"
 
-export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
+export type IntegrationOAuthAuthorization = {
+  readonly url: string
+  readonly instructions: string
+} & (
+  | {
+      readonly mode: "auto"
+      readonly callback: Effect.Effect<CredentialOAuth, unknown>
+    }
+  | {
+      readonly mode: "code"
+      readonly callback: (code: string) => Effect.Effect<CredentialOAuth, unknown>
+    }
+)
+export type IntegrationOAuthMethodRegistration = {
+  readonly integrationID: string
+  readonly method: IntegrationOAuthMethod
+  readonly authorize: (
+    inputs: IntegrationInputs,
+  ) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
+  readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
+  readonly label?: (credential: CredentialOAuth) => string | undefined
+}
 export type IntegrationMethodRegistration =
+  | IntegrationOAuthMethodRegistration
   | {
       readonly integrationID: string
       readonly method: IntegrationKeyMethod
@@ -18,9 +46,9 @@ export type IntegrationMethodRegistration =
     }
 
 export interface IntegrationDraft {
-  list(): readonly Pick<IntegrationInfo, "id" | "name">[]
-  get(id: string): Pick<IntegrationInfo, "id" | "name"> | undefined
-  update(id: string, update: (integration: Pick<IntegrationInfo, "id" | "name">) => void): void
+  list(): readonly IntegrationRef[]
+  get(id: string): IntegrationRef | undefined
+  update(id: string, update: (integration: IntegrationRef) => void): void
   remove(id: string): void
   readonly method: {
     list(integrationID: string): readonly IntegrationMethod[]
@@ -29,6 +57,9 @@ export interface IntegrationDraft {
   }
 }
 
-export type IntegrationHooks = Hooks<{
-  transform: IntegrationDraft
-}>
+export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> {
+  readonly connection: {
+    readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
+    readonly resolve: (connection: ConnectionInfo) => Effect.Effect<CredentialValue | undefined, unknown>
+  }
+}

+ 3 - 3
packages/plugin/src/v2/effect/plugin.ts

@@ -1,12 +1,12 @@
 import type { Effect, Scope } from "effect"
 import type { PluginContext } from "./context.js"
 
-export interface Plugin {
+export interface Plugin<R = Scope.Scope> {
   readonly id: string
-  readonly effect: (context: PluginContext) => Effect.Effect<void, never, Scope.Scope>
+  readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
 }
 
-export function define(plugin: Plugin) {
+export function define<R = Scope.Scope>(plugin: Plugin<R>) {
   return plugin
 }
 

+ 3 - 8
packages/plugin/src/v2/effect/skill.ts

@@ -1,14 +1,9 @@
-import type { SkillV2Info } from "@opencode-ai/sdk/v2/types"
+import type { SkillV2Source } from "@opencode-ai/sdk/v2/types"
 import type { Hooks } from "./registration.js"
 
-export type SkillSource =
-  | { readonly type: "directory"; readonly path: string }
-  | { readonly type: "url"; readonly url: string }
-  | { readonly type: "embedded"; readonly skill: SkillV2Info }
-
 export interface SkillDraft {
-  source(source: SkillSource): void
-  list(): readonly SkillSource[]
+  source(source: SkillV2Source): void
+  list(): readonly SkillV2Source[]
 }
 
 export type SkillHooks = Hooks<{

+ 1 - 2
packages/plugin/src/v2/promise/index.ts

@@ -10,8 +10,7 @@ export type { CommandDraft, CommandHooks } from "./command.js"
 export type {
   IntegrationDraft,
   IntegrationHooks,
-  IntegrationMethod,
   IntegrationMethodRegistration,
 } from "./integration.js"
 export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
-export type { SkillDraft, SkillHooks, SkillSource } from "./skill.js"
+export type { SkillDraft, SkillHooks } from "./skill.js"

+ 16 - 5
packages/plugin/src/v2/promise/integration.ts

@@ -1,8 +1,19 @@
-import type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "../effect/integration.js"
+import type {
+  IntegrationDraft,
+  IntegrationMethodRegistration,
+} from "../effect/integration.js"
+import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
 import type { Hooks } from "./registration.js"
 
-export type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration }
+export type { IntegrationDraft, IntegrationMethodRegistration }
 
-export type IntegrationHooks = Hooks<{
-  transform: IntegrationDraft
-}>
+export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> {
+  readonly connection: {
+    readonly active: (
+      integrationID: string,
+    ) => Promise<import("@opencode-ai/sdk/v2/types").ConnectionInfo | undefined>
+    readonly resolve: (
+      connection: import("@opencode-ai/sdk/v2/types").ConnectionInfo,
+    ) => Promise<CredentialValue | undefined>
+  }
+}

+ 2 - 2
packages/plugin/src/v2/promise/skill.ts

@@ -1,7 +1,7 @@
-import type { SkillDraft, SkillSource } from "../effect/skill.js"
+import type { SkillDraft } from "../effect/skill.js"
 import type { Hooks } from "./registration.js"
 
-export type { SkillDraft, SkillSource }
+export type { SkillDraft }
 
 export type SkillHooks = Hooks<{
   transform: SkillDraft

+ 84 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -7,6 +7,7 @@ export type ClientOptions = {
 export type Event =
   | EventModelsDevRefreshed
   | EventIntegrationUpdated
+  | EventIntegrationConnectionUpdated
   | EventCatalogUpdated
   | EventSessionCreated
   | EventSessionUpdated
@@ -742,6 +743,13 @@ export type GlobalEvent = {
           [key: string]: unknown
         }
       }
+    | {
+        id: string
+        type: "integration.connection.updated"
+        properties: {
+          integrationID: string
+        }
+      }
     | {
         id: string
         type: "catalog.updated"
@@ -2744,6 +2752,7 @@ export type ProviderNotFoundError = {
 export type V2Event =
   | V2EventModelsDevRefreshed
   | V2EventIntegrationUpdated
+  | V2EventIntegrationConnectionUpdated
   | V2EventCatalogUpdated
   | V2EventSessionCreated
   | V2EventSessionUpdated
@@ -2899,6 +2908,21 @@ export type EventTuiSessionSelect2 = {
   }
 }
 
+export type CredentialValue = CredentialOAuth | CredentialKey
+
+export type IntegrationInputs = {
+  [key: string]: string
+}
+
+export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
+
+export type IntegrationRef = {
+  id: string
+  name: string
+}
+
+export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource
+
 export type MoveSessionDestination = {
   directory: string
 }
@@ -4145,7 +4169,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
 export type IntegrationInfo = {
   id: string
   name: string
-  methods: Array<IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod>
+  methods: Array<IntegrationMethod>
   connections: Array<ConnectionInfo>
 }
 
@@ -4240,6 +4264,23 @@ export type V2EventIntegrationUpdated = {
   }
 }
 
+export type V2EventIntegrationConnectionUpdated = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  durable?: {
+    aggregateID: string
+    seq: number
+    version: number
+  }
+  location?: LocationRef
+  type: "integration.connection.updated"
+  data: {
+    integrationID: string
+  }
+}
+
 export type V2EventCatalogUpdated = {
   id: string
   metadata?: {
@@ -5981,6 +6022,14 @@ export type EventIntegrationUpdated = {
   }
 }
 
+export type EventIntegrationConnectionUpdated = {
+  id: string
+  type: "integration.connection.updated"
+  properties: {
+    integrationID: string
+  }
+}
+
 export type EventCatalogUpdated = {
   id: string
   type: "catalog.updated"
@@ -6869,6 +6918,40 @@ export type EventGlobalDisposed = {
   }
 }
 
+export type CredentialOAuth = {
+  type: "oauth"
+  methodID: string
+  refresh: string
+  access: string
+  expires: number
+  metadata?: {
+    [key: string]: unknown
+  }
+}
+
+export type CredentialKey = {
+  type: "key"
+  key: string
+  metadata?: {
+    [key: string]: unknown
+  }
+}
+
+export type SkillV2DirectorySource = {
+  type: "directory"
+  path: string
+}
+
+export type SkillV2UrlSource = {
+  type: "url"
+  url: string
+}
+
+export type SkillV2EmbeddedSource = {
+  type: "embedded"
+  skill: SkillV2Info
+}
+
 export type BadRequestError = {
   name: "BadRequest"
   data: {