index.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import type { Hooks, PluginInput, Plugin as PluginInstance } 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 { Server } from "../server/server"
  7. import { BunProc } from "../bun"
  8. import { Instance } from "../project/instance"
  9. import { Flag } from "../flag/flag"
  10. import { CodexAuthPlugin } from "./codex"
  11. import { Session } from "../session"
  12. import { NamedError } from "@opencode-ai/util/error"
  13. import { CopilotAuthPlugin } from "./copilot"
  14. import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
  15. export namespace Plugin {
  16. const log = Log.create({ service: "plugin" })
  17. // Built-in plugins that are directly imported (not installed from npm)
  18. const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
  19. const state = Instance.state(async () => {
  20. const client = createOpencodeClient({
  21. baseUrl: "http://localhost:4096",
  22. directory: Instance.directory,
  23. headers: Flag.OPENCODE_SERVER_PASSWORD
  24. ? {
  25. Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
  26. }
  27. : undefined,
  28. fetch: async (...args) => Server.Default().fetch(...args),
  29. })
  30. const config = await Config.get()
  31. const hooks: Hooks[] = []
  32. const input: PluginInput = {
  33. client,
  34. project: Instance.project,
  35. worktree: Instance.worktree,
  36. directory: Instance.directory,
  37. get serverUrl(): URL {
  38. return Server.url ?? new URL("http://localhost:4096")
  39. },
  40. $: Bun.$,
  41. }
  42. for (const plugin of INTERNAL_PLUGINS) {
  43. log.info("loading internal plugin", { name: plugin.name })
  44. const init = await plugin(input).catch((err) => {
  45. log.error("failed to load internal plugin", { name: plugin.name, error: err })
  46. })
  47. if (init) hooks.push(init)
  48. }
  49. let plugins = config.plugin ?? []
  50. if (plugins.length) await Config.waitForDependencies()
  51. for (let plugin of plugins) {
  52. // ignore old codex plugin since it is supported first party now
  53. if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
  54. log.info("loading plugin", { path: plugin })
  55. if (!plugin.startsWith("file://")) {
  56. const lastAtIndex = plugin.lastIndexOf("@")
  57. const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
  58. const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
  59. plugin = await BunProc.install(pkg, version).catch((err) => {
  60. const cause = err instanceof Error ? err.cause : err
  61. const detail = cause instanceof Error ? cause.message : String(cause ?? err)
  62. log.error("failed to install plugin", { pkg, version, error: detail })
  63. Bus.publish(Session.Event.Error, {
  64. error: new NamedError.Unknown({
  65. message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
  66. }).toObject(),
  67. })
  68. return ""
  69. })
  70. if (!plugin) continue
  71. }
  72. // Prevent duplicate initialization when plugins export the same function
  73. // as both a named export and default export (e.g., `export const X` and `export default X`).
  74. // Object.entries(mod) would return both entries pointing to the same function reference.
  75. await import(plugin)
  76. .then(async (mod) => {
  77. const seen = new Set<PluginInstance>()
  78. for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
  79. if (seen.has(fn)) continue
  80. seen.add(fn)
  81. hooks.push(await fn(input))
  82. }
  83. })
  84. .catch((err) => {
  85. const message = err instanceof Error ? err.message : String(err)
  86. log.error("failed to load plugin", { path: plugin, error: message })
  87. Bus.publish(Session.Event.Error, {
  88. error: new NamedError.Unknown({
  89. message: `Failed to load plugin ${plugin}: ${message}`,
  90. }).toObject(),
  91. })
  92. })
  93. }
  94. return {
  95. hooks,
  96. input,
  97. }
  98. })
  99. export async function trigger<
  100. Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
  101. Input = Parameters<Required<Hooks>[Name]>[0],
  102. Output = Parameters<Required<Hooks>[Name]>[1],
  103. >(name: Name, input: Input, output: Output): Promise<Output> {
  104. if (!name) return output
  105. for (const hook of await state().then((x) => x.hooks)) {
  106. const fn = hook[name]
  107. if (!fn) continue
  108. // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
  109. // give up.
  110. // try-counter: 2
  111. await fn(input, output)
  112. }
  113. return output
  114. }
  115. export async function list() {
  116. return state().then((x) => x.hooks)
  117. }
  118. export async function init() {
  119. const hooks = await state().then((x) => x.hooks)
  120. const config = await Config.get()
  121. for (const hook of hooks) {
  122. // @ts-expect-error this is because we haven't moved plugin to sdk v2
  123. await hook.config?.(config)
  124. }
  125. Bus.subscribeAll(async (input) => {
  126. const hooks = await state().then((x) => x.hooks)
  127. for (const hook of hooks) {
  128. hook["event"]?.({
  129. event: input,
  130. })
  131. }
  132. })
  133. }
  134. }