index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
  2. import { Config } from "../config/config"
  3. import { Bus } from "../bus"
  4. import { Log } from "../util/log"
  5. import { createOpencodeClient } from "@opencode-ai/sdk"
  6. import { Flag } from "../flag/flag"
  7. import { CodexAuthPlugin } from "./codex"
  8. import { Session } from "../session"
  9. import { NamedError } from "@opencode-ai/util/error"
  10. import { CopilotAuthPlugin } from "./github-copilot/copilot"
  11. import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
  12. import { PoeAuthPlugin } from "opencode-poe-auth"
  13. import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
  14. import { Effect, Layer, ServiceMap, Stream } from "effect"
  15. import { InstanceState } from "@/effect/instance-state"
  16. import { makeRuntime } from "@/effect/run-service"
  17. import { errorMessage } from "@/util/error"
  18. import { PluginLoader } from "./loader"
  19. import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
  20. export namespace Plugin {
  21. const log = Log.create({ service: "plugin" })
  22. type State = {
  23. hooks: Hooks[]
  24. }
  25. // Hook names that follow the (input, output) => Promise<void> trigger pattern
  26. type TriggerName = {
  27. [K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
  28. }[keyof Hooks]
  29. export interface Interface {
  30. readonly trigger: <
  31. Name extends TriggerName,
  32. Input = Parameters<Required<Hooks>[Name]>[0],
  33. Output = Parameters<Required<Hooks>[Name]>[1],
  34. >(
  35. name: Name,
  36. input: Input,
  37. output: Output,
  38. ) => Effect.Effect<Output>
  39. readonly list: () => Effect.Effect<Hooks[]>
  40. readonly init: () => Effect.Effect<void>
  41. }
  42. export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
  43. // Built-in plugins that are directly imported (not installed from npm)
  44. const INTERNAL_PLUGINS: PluginInstance[] = [
  45. CodexAuthPlugin,
  46. CopilotAuthPlugin,
  47. GitlabAuthPlugin,
  48. PoeAuthPlugin,
  49. CloudflareWorkersAuthPlugin,
  50. CloudflareAIGatewayAuthPlugin,
  51. ]
  52. function isServerPlugin(value: unknown): value is PluginInstance {
  53. return typeof value === "function"
  54. }
  55. function getServerPlugin(value: unknown) {
  56. if (isServerPlugin(value)) return value
  57. if (!value || typeof value !== "object" || !("server" in value)) return
  58. if (!isServerPlugin(value.server)) return
  59. return value.server
  60. }
  61. function getLegacyPlugins(mod: Record<string, unknown>) {
  62. const seen = new Set<unknown>()
  63. const result: PluginInstance[] = []
  64. for (const entry of Object.values(mod)) {
  65. if (seen.has(entry)) continue
  66. seen.add(entry)
  67. const plugin = getServerPlugin(entry)
  68. if (!plugin) throw new TypeError("Plugin export is not a function")
  69. result.push(plugin)
  70. }
  71. return result
  72. }
  73. function publishPluginError(bus: Bus.Interface, message: string) {
  74. Effect.runFork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
  75. }
  76. async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
  77. const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
  78. if (plugin) {
  79. await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
  80. hooks.push(await (plugin as PluginModule).server(input, load.options))
  81. return
  82. }
  83. for (const server of getLegacyPlugins(load.mod)) {
  84. hooks.push(await server(input, load.options))
  85. }
  86. }
  87. export const layer = Layer.effect(
  88. Service,
  89. Effect.gen(function* () {
  90. const bus = yield* Bus.Service
  91. const config = yield* Config.Service
  92. const state = yield* InstanceState.make<State>(
  93. Effect.fn("Plugin.state")(function* (ctx) {
  94. const hooks: Hooks[] = []
  95. const { Server } = yield* Effect.promise(() => import("../server/server"))
  96. const client = createOpencodeClient({
  97. baseUrl: "http://localhost:4096",
  98. directory: ctx.directory,
  99. headers: Flag.OPENCODE_SERVER_PASSWORD
  100. ? {
  101. Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
  102. }
  103. : undefined,
  104. fetch: async (...args) => Server.Default().fetch(...args),
  105. })
  106. const cfg = yield* config.get()
  107. const input: PluginInput = {
  108. client,
  109. project: ctx.project,
  110. worktree: ctx.worktree,
  111. directory: ctx.directory,
  112. get serverUrl(): URL {
  113. return Server.url ?? new URL("http://localhost:4096")
  114. },
  115. $: Bun.$,
  116. }
  117. for (const plugin of INTERNAL_PLUGINS) {
  118. log.info("loading internal plugin", { name: plugin.name })
  119. const init = yield* Effect.tryPromise({
  120. try: () => plugin(input),
  121. catch: (err) => {
  122. log.error("failed to load internal plugin", { name: plugin.name, error: err })
  123. },
  124. }).pipe(Effect.option)
  125. if (init._tag === "Some") hooks.push(init.value)
  126. }
  127. const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
  128. if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
  129. log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
  130. }
  131. if (plugins.length) yield* config.waitForDependencies()
  132. const loaded = yield* Effect.promise(() =>
  133. PluginLoader.loadExternal({
  134. items: plugins,
  135. kind: "server",
  136. report: {
  137. start(candidate) {
  138. log.info("loading plugin", { path: candidate.plan.spec })
  139. },
  140. missing(candidate, _retry, message) {
  141. log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
  142. },
  143. error(candidate, _retry, stage, error, resolved) {
  144. const spec = candidate.plan.spec
  145. const cause = error instanceof Error ? (error.cause ?? error) : error
  146. const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
  147. if (stage === "install") {
  148. const parsed = parsePluginSpecifier(spec)
  149. log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
  150. publishPluginError(bus, `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
  151. return
  152. }
  153. if (stage === "compatibility") {
  154. log.warn("plugin incompatible", { path: spec, error: message })
  155. publishPluginError(bus, `Plugin ${spec} skipped: ${message}`)
  156. return
  157. }
  158. if (stage === "entry") {
  159. log.error("failed to resolve plugin server entry", { path: spec, error: message })
  160. publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`)
  161. return
  162. }
  163. log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
  164. publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`)
  165. },
  166. },
  167. }),
  168. )
  169. for (const load of loaded) {
  170. if (!load) continue
  171. // Keep plugin execution sequential so hook registration and execution
  172. // order remains deterministic across plugin runs.
  173. yield* Effect.tryPromise({
  174. try: () => applyPlugin(load, input, hooks),
  175. catch: (err) => {
  176. const message = errorMessage(err)
  177. log.error("failed to load plugin", { path: load.spec, error: message })
  178. return message
  179. },
  180. }).pipe(
  181. Effect.catch((message) =>
  182. bus.publish(Session.Event.Error, {
  183. error: new NamedError.Unknown({
  184. message: `Failed to load plugin ${load.spec}: ${message}`,
  185. }).toObject(),
  186. }),
  187. ),
  188. )
  189. }
  190. // Notify plugins of current config
  191. for (const hook of hooks) {
  192. yield* Effect.tryPromise({
  193. try: () => Promise.resolve((hook as any).config?.(cfg)),
  194. catch: (err) => {
  195. log.error("plugin config hook failed", { error: err })
  196. },
  197. }).pipe(Effect.ignore)
  198. }
  199. // Subscribe to bus events, fiber interrupted when scope closes
  200. yield* bus.subscribeAll().pipe(
  201. Stream.runForEach((input) =>
  202. Effect.sync(() => {
  203. for (const hook of hooks) {
  204. hook["event"]?.({ event: input as any })
  205. }
  206. }),
  207. ),
  208. Effect.forkScoped,
  209. )
  210. return { hooks }
  211. }),
  212. )
  213. const trigger = Effect.fn("Plugin.trigger")(function* <
  214. Name extends TriggerName,
  215. Input = Parameters<Required<Hooks>[Name]>[0],
  216. Output = Parameters<Required<Hooks>[Name]>[1],
  217. >(name: Name, input: Input, output: Output) {
  218. if (!name) return output
  219. const s = yield* InstanceState.get(state)
  220. for (const hook of s.hooks) {
  221. const fn = hook[name] as any
  222. if (!fn) continue
  223. yield* Effect.promise(async () => fn(input, output))
  224. }
  225. return output
  226. })
  227. const list = Effect.fn("Plugin.list")(function* () {
  228. const s = yield* InstanceState.get(state)
  229. return s.hooks
  230. })
  231. const init = Effect.fn("Plugin.init")(function* () {
  232. yield* InstanceState.get(state)
  233. })
  234. return Service.of({ trigger, list, init })
  235. }),
  236. )
  237. export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
  238. const { runPromise } = makeRuntime(Service, defaultLayer)
  239. export async function trigger<
  240. Name extends TriggerName,
  241. Input = Parameters<Required<Hooks>[Name]>[0],
  242. Output = Parameters<Required<Hooks>[Name]>[1],
  243. >(name: Name, input: Input, output: Output): Promise<Output> {
  244. return runPromise((svc) => svc.trigger(name, input, output))
  245. }
  246. export async function list(): Promise<Hooks[]> {
  247. return runPromise((svc) => svc.list())
  248. }
  249. export async function init() {
  250. return runPromise((svc) => svc.init())
  251. }
  252. }