Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .fork-features/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,34 @@
"relatedIssues": [],
"absorptionSignals": ["getApfelSuggestion", "apfelPath.*Bun.which", "apfel.*permissive.*permission"]
}
},
"plugin-loading": {
"status": "active",
"description": "Restored plugin loading in init() to load internal and external plugins. Upstream uses Effect-based init(); our fork uses direct imperative loading with PluginLoader.loadExternal(). Diverges from upstream plugin/index.ts which uses Effect Layer pattern.",
"issue": "https://github.com/randomm/opencode/issues/408",
"newFiles": [],
"deletedFiles": [],
"modifiedFiles": ["packages/opencode/src/plugin/index.ts"],
"criticalCode": [
"Plugin.init",
"PluginLoader.loadExternal",
"INTERNAL_PLUGINS",
"applyPlugin",
"createOpencodeClient",
"Server.App().fetch",
"OPENCODE_DISABLE_DEFAULT_PLUGINS"
],
"tests": ["packages/opencode/test/plugin/plugin-loading.test.ts"],
"upstreamTracking": {
"relatedPRs": ["anomalyco/opencode#7206"],
"relatedIssues": ["anomalyco/opencode#5887"],
"absorptionSignals": [
"Plugin.init.*loadExternal",
"createOpencodeClient.*Server.App",
"imperative plugin loading",
"OPENCODE_DISABLE_DEFAULT_PLUGINS"
]
}
}
}
}
67 changes: 59 additions & 8 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
import { $ } from "bun"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { PluginLoader } from "./loader"
import { errorMessage } from "@/util/error"
import { Config } from "../config/config"
import { Server } from "../server/server"
import { Instance } from "../project/instance"
import { createOpencodeClient } from "@opencode-ai/sdk"

export namespace Plugin {
const log = Log.create({ service: "plugin" })
Expand All @@ -21,8 +23,9 @@ export namespace Plugin {
function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin((value as any).server)) return
return (value as any).server
const server = (value as PluginModule).server
if (!isServerPlugin(server)) return
return server
}

function getLegacyPlugins(mod: Record<string, unknown>) {
Expand Down Expand Up @@ -52,12 +55,60 @@ export namespace Plugin {
}

export async function init() {
log.info("plugin system stub - init called")
log.info("plugin init called")
hooks = [] // Reset hooks to prevent accumulation on re-init

const config = await Config.get()
const client = createOpencodeClient({
baseUrl: Server.url().origin,
directory: Instance.directory,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
return Server.App().fetch(request)
},
})

const input: PluginInput = {
client,
project: Instance.project,
worktree: Instance.worktree,
directory: Instance.directory,
serverUrl: Server.url(),
$: $,
}

// Load built-in plugins (unless disabled via flag)
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const hook = await plugin(input)
hooks.push(hook)
}
}

// Load external plugins from config directories (e.g. ~/.config/opencode/plugins/*.js)
if (config.plugin && config.plugin.length > 0) {
const origins = config.plugin.map((spec) => ({ spec }))
await PluginLoader.loadExternal({
items: origins,
kind: "server",
finish: async (loaded) => {
log.info("loaded external plugin", { spec: loaded.spec })
await applyPlugin(loaded, input)
return loaded
},
report: {
error: (candidate, _retry, stage, error) => {
log.error("failed to load external plugin", { spec: candidate.plan.spec, stage, error })
},
},
})
}
}

export async function trigger<Output>(name: string, input: unknown, output: Output): Promise<Output> {
for (const hook of hooks) {
const fn = (hook as any)[name]
const fn = hook[name as keyof Hooks] as ((input: unknown, output: unknown) => Promise<void>) | undefined
if (fn) await fn(input, output)
}
return output
Expand Down
158 changes: 158 additions & 0 deletions packages/opencode/test/plugin/plugin-loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Plugin } from "../../src/plugin"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"

function mockConfig(plugin: string[] = []): Config.Info {
return { plugin } as Config.Info
}

describe("plugin-loading", () => {
test("init() completes without error when called with empty config", async () => {
await using tmp = await tmpdir({
config: { model: "test/model" },
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
// Mock Config.get to return no external plugins
const originalGet = Config.get
Config.get = async () => mockConfig()

try {
// Call init - should complete without throwing
await Plugin.init()

// Verify init completed - hooks should be populated with internal plugins
const hooks = await Plugin.list()
expect(Array.isArray(hooks)).toBe(true)
expect(hooks.length).toBeGreaterThan(0)
} finally {
Config.get = originalGet
}
},
})
})

test("init() loads external plugins when config.plugin has entries", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })

await Bun.write(
path.join(pluginDir, "test-plugin.ts"),
[
"export default async (input) => ({",
" auth: {",
' provider: "test",',
" methods: [{ type: 'api', label: 'Test Plugin Auth' }],",
" },",
"})",
"",
].join("\n"),
)

return path.join(pluginDir, "test-plugin.ts")
},
config: { model: "test/model" },
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
// Mock Config.get to return external plugin path
const originalGet = Config.get
Config.get = async () => mockConfig([tmp.extra as string])

try {
// Call init - external plugin should load
await Plugin.init()

// Verify init completed
const hooks = await Plugin.list()
expect(Array.isArray(hooks)).toBe(true)
} finally {
Config.get = originalGet
}
},
})
}, 30000)

test("init() handles non-existent external plugin gracefully", async () => {
await using tmp = await tmpdir({
config: { model: "test/model" },
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
// Mock Config.get to return a non-existent plugin
const originalGet = Config.get
Config.get = async () => mockConfig(["file:///non/existent/plugin.js"])

try {
// Call init - should NOT throw even with non-existent plugin
await Plugin.init()

// init completes successfully despite plugin load failure
expect(true).toBe(true)
} finally {
Config.get = originalGet
}
},
})
})

test("trigger() works after init() is called", async () => {
await using tmp = await tmpdir({
config: { model: "test/model" },
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
// Mock Config.get to return no external plugins
const originalGet = Config.get
Config.get = async () => mockConfig()

try {
// Call init first
await Plugin.init()

// trigger should work without throwing
const result = await Plugin.trigger("nonExistentHook", {}, { original: true })
expect(result).toEqual({ original: true })
} finally {
Config.get = originalGet
}
},
})
})

test("list() returns array of hooks after init()", async () => {
await using tmp = await tmpdir({
config: { model: "test/model" },
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const originalGet = Config.get
Config.get = async () => mockConfig()

try {
await Plugin.init()
const hooks = await Plugin.list()
expect(Array.isArray(hooks)).toBe(true)
} finally {
Config.get = originalGet
}
},
})
})
})
Loading