Skip to content
This repository was archived by the owner on Feb 19, 2026. It is now read-only.

Commit ca03127

Browse files
committed
wip: plugins
1 parent ae6e47b commit ca03127

38 files changed

+2784
-2500
lines changed

bun.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

opencode.json

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
3-
"provider": {
4-
"cerebras": {
5-
"options": {
6-
"apiKey": "csk-m33xrvt43whkypn8r4ph9xc8fenhx2f68c3pj22ext45v5k9"
7-
},
8-
"npm": "@ai-sdk/cerebras",
9-
"models": {
10-
"qwen-3-coder-480b": {}
11-
}
12-
}
13-
},
3+
"plugin": ["./packages/plugin/src/example.ts"],
144
"mcp": {
155
"context7": {
166
"type": "remote",

packages/function/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opencode/function",
3-
"version": "0.0.1",
3+
"version": "0.0.0-202508022246",
44
"$schema": "https://json.schemastore.org/package.json",
55
"private": true,
66
"type": "module",

packages/opencode/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
3-
"version": "0.0.0",
3+
"version": "0.0.0-202508022246",
44
"name": "opencode",
55
"type": "module",
66
"private": true,
@@ -36,6 +36,7 @@
3636
"@octokit/graphql": "9.0.1",
3737
"@octokit/rest": "22.0.0",
3838
"@openauthjs/openauth": "0.4.3",
39+
"@opencode-ai/plugin": "workspace:*",
3940
"@standard-schema/spec": "1.0.0",
4041
"@zip.js/zip.js": "2.7.62",
4142
"ai": "catalog:",

packages/opencode/src/cli/bootstrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { App } from "../app/app"
22
import { ConfigHooks } from "../config/hooks"
33
import { Format } from "../format"
44
import { LSP } from "../lsp"
5+
import { Plugin } from "../plugin"
56
import { Share } from "../share/share"
67
import { Snapshot } from "../snapshot"
78

89
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
910
return App.provide(input, async (app) => {
1011
Share.init()
1112
Format.init()
13+
Plugin.init()
1214
ConfigHooks.init()
1315
LSP.init()
1416
Snapshot.init()

packages/opencode/src/config/config.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,21 @@ export namespace Config {
2323
for (const file of ["opencode.jsonc", "opencode.json"]) {
2424
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
2525
for (const resolved of found.toReversed()) {
26-
result = mergeDeep(result, await load(resolved))
26+
result = mergeDeep(result, await loadFile(resolved))
2727
}
2828
}
2929

3030
// Override with custom config if provided
3131
if (Flag.OPENCODE_CONFIG) {
32-
result = mergeDeep(result, await load(Flag.OPENCODE_CONFIG))
32+
result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
3333
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
3434
}
3535

3636
for (const [key, value] of Object.entries(auth)) {
3737
if (value.type === "wellknown") {
3838
process.env[value.key] = value.token
3939
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
40-
result = mergeDeep(result, await loadRaw(JSON.stringify(wellknown.config ?? {}), process.cwd()))
40+
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
4141
}
4242
}
4343

@@ -223,6 +223,7 @@ export namespace Config {
223223
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
224224
theme: z.string().optional().describe("Theme name to use for the interface"),
225225
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
226+
plugin: z.string().array().optional(),
226227
share: z
227228
.enum(["manual", "auto", "disabled"])
228229
.optional()
@@ -352,9 +353,9 @@ export namespace Config {
352353
export const global = lazy(async () => {
353354
let result: Info = pipe(
354355
{},
355-
mergeDeep(await load(path.join(Global.Path.config, "config.json"))),
356-
mergeDeep(await load(path.join(Global.Path.config, "opencode.json"))),
357-
mergeDeep(await load(path.join(Global.Path.config, "opencode.jsonc"))),
356+
mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
357+
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
358+
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
358359
)
359360

360361
await import(path.join(Global.Path.config, "config"), {
@@ -375,25 +376,26 @@ export namespace Config {
375376
return result
376377
})
377378

378-
async function load(configPath: string): Promise<Info> {
379-
let text = await Bun.file(configPath)
379+
async function loadFile(filepath: string): Promise<Info> {
380+
log.info("loading", { path: filepath })
381+
let text = await Bun.file(filepath)
380382
.text()
381383
.catch((err) => {
382384
if (err.code === "ENOENT") return
383-
throw new JsonError({ path: configPath }, { cause: err })
385+
throw new JsonError({ path: filepath }, { cause: err })
384386
})
385387
if (!text) return {}
386-
return loadRaw(text, configPath)
388+
return load(text, filepath)
387389
}
388390

389-
async function loadRaw(text: string, configPath: string) {
391+
async function load(text: string, filepath: string) {
390392
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
391393
return process.env[varName] || ""
392394
})
393395

394396
const fileMatches = text.match(/\{file:[^}]+\}/g)
395397
if (fileMatches) {
396-
const configDir = path.dirname(configPath)
398+
const configDir = path.dirname(filepath)
397399
const lines = text.split("\n")
398400

399401
for (const match of fileMatches) {
@@ -428,7 +430,7 @@ export namespace Config {
428430
.join("\n")
429431

430432
throw new JsonError({
431-
path: configPath,
433+
path: filepath,
432434
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
433435
})
434436
}
@@ -437,11 +439,21 @@ export namespace Config {
437439
if (parsed.success) {
438440
if (!parsed.data.$schema) {
439441
parsed.data.$schema = "https://opencode.ai/config.json"
440-
await Bun.write(configPath, JSON.stringify(parsed.data, null, 2))
442+
await Bun.write(filepath, JSON.stringify(parsed.data, null, 2))
441443
}
442-
return parsed.data
444+
const data = parsed.data
445+
if (data.plugin) {
446+
for (let i = 0; i < data.plugin?.length; i++) {
447+
const plugin = data.plugin[i]
448+
if (typeof plugin === "string") {
449+
data.plugin[i] = path.resolve(path.dirname(filepath), plugin)
450+
}
451+
}
452+
}
453+
return data
443454
}
444-
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
455+
456+
throw new InvalidError({ path: filepath, issues: parsed.error.issues })
445457
}
446458
export const JsonError = NamedError.create(
447459
"ConfigJsonError",

packages/opencode/src/permission/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod"
33
import { Bus } from "../bus"
44
import { Log } from "../util/log"
55
import { Identifier } from "../id/id"
6+
import { Plugin } from "../plugin"
67

78
export namespace Permission {
89
const log = Log.create({ service: "permission" })
@@ -67,7 +68,7 @@ export namespace Permission {
6768
},
6869
)
6970

70-
export function ask(input: {
71+
export async function ask(input: {
7172
type: Info["type"]
7273
title: Info["title"]
7374
pattern?: Info["pattern"]
@@ -95,6 +96,18 @@ export namespace Permission {
9596
created: Date.now(),
9697
},
9798
}
99+
100+
switch (
101+
await Plugin.trigger("permission.ask", info, {
102+
status: "ask",
103+
}).then((x) => x.status)
104+
) {
105+
case "deny":
106+
throw new RejectedError(info.sessionID, info.id, info.callID)
107+
case "allow":
108+
return
109+
}
110+
98111
pending[input.sessionID] = pending[input.sessionID] || {}
99112
return new Promise<void>((resolve, reject) => {
100113
pending[input.sessionID][info.id] = {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
2+
import { App } from "../app/app"
3+
import { Config } from "../config/config"
4+
import { Bus } from "../bus"
5+
import { Log } from "../util/log"
6+
import { createOpencodeClient } from "@opencode-ai/sdk"
7+
import { Server } from "../server/server"
8+
import { pathOr } from "remeda"
9+
10+
export namespace Plugin {
11+
const log = Log.create({ service: "plugin" })
12+
13+
const state = App.state("plugin", async (app) => {
14+
const client = createOpencodeClient({
15+
baseUrl: "http://localhost:4096",
16+
fetch: async (...args) => Server.app().fetch(...args),
17+
})
18+
const config = await Config.get()
19+
const hooks = []
20+
for (const plugin of config.plugin ?? []) {
21+
log.info("loading plugin", { path: plugin })
22+
const mod = await import(plugin)
23+
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
24+
const init = await fn({
25+
client,
26+
app,
27+
$: Bun.$,
28+
})
29+
hooks.push(init)
30+
}
31+
}
32+
33+
return {
34+
hooks,
35+
}
36+
})
37+
38+
type Path<T, Prefix extends string = ""> = T extends object
39+
? {
40+
[K in keyof T]: K extends string
41+
? T[K] extends Function | undefined
42+
? `${Prefix}${K}`
43+
: Path<T[K], `${Prefix}${K}.`>
44+
: never
45+
}[keyof T]
46+
: never
47+
48+
export type FunctionFromKey<T, P extends Path<T>> = P extends `${infer K}.${infer R}`
49+
? K extends keyof T
50+
? R extends Path<T[K]>
51+
? FunctionFromKey<T[K], R>
52+
: never
53+
: never
54+
: P extends keyof T
55+
? T[P]
56+
: never
57+
58+
export async function trigger<
59+
Name extends Path<Required<Hooks>>,
60+
Input = Parameters<FunctionFromKey<Required<Hooks>, Name>>[0],
61+
Output = Parameters<FunctionFromKey<Required<Hooks>, Name>>[1],
62+
>(fn: Name, input: Input, output: Output): Promise<Output> {
63+
if (!fn) return output
64+
const path = fn.split(".")
65+
for (const hook of await state().then((x) => x.hooks)) {
66+
// @ts-expect-error
67+
const fn = pathOr(hook, path, undefined)
68+
if (!fn) continue
69+
// @ts-expect-error
70+
await fn(input, output)
71+
}
72+
return output
73+
}
74+
75+
export function init() {
76+
Bus.subscribeAll(async (input) => {
77+
const hooks = await state().then((x) => x.hooks)
78+
for (const hook of hooks) {
79+
hook["event"]?.({
80+
event: input,
81+
})
82+
}
83+
})
84+
}
85+
}

packages/opencode/src/provider/provider.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export namespace Provider {
9797
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
9898
)
9999
}
100-
} catch { }
100+
} catch {}
101101
const headers: Record<string, string> = {
102102
...init.headers,
103103
...copilot.HEADERS,
@@ -283,26 +283,26 @@ export namespace Provider {
283283
cost:
284284
!model.cost && !existing?.cost
285285
? {
286-
input: 0,
287-
output: 0,
288-
cache_read: 0,
289-
cache_write: 0,
290-
}
286+
input: 0,
287+
output: 0,
288+
cache_read: 0,
289+
cache_write: 0,
290+
}
291291
: {
292-
cache_read: 0,
293-
cache_write: 0,
294-
...existing?.cost,
295-
...model.cost,
296-
},
292+
cache_read: 0,
293+
cache_write: 0,
294+
...existing?.cost,
295+
...model.cost,
296+
},
297297
options: {
298298
...existing?.options,
299299
...model.options,
300300
},
301301
limit: model.limit ??
302302
existing?.limit ?? {
303-
context: 0,
304-
output: 0,
305-
},
303+
context: 0,
304+
output: 0,
305+
},
306306
}
307307
parsed.models[modelID] = parsedModel
308308
}
@@ -386,6 +386,10 @@ export namespace Provider {
386386
})
387387
}
388388

389+
export async function getProvider(providerID: string) {
390+
return state().then((s) => s.providers[providerID])
391+
}
392+
389393
export async function getModel(providerID: string, modelID: string) {
390394
const key = `${providerID}/${modelID}`
391395
const s = await state()

0 commit comments

Comments
 (0)