Merge remote-tracking branch 'origin/dev' into pr-18308

# Conflicts:
#	packages/opencode/src/bun/index.ts
#	packages/opencode/test/bun.test.ts
This commit is contained in:
Dax Raad
2026-04-01 11:55:26 -04:00
343 changed files with 84904 additions and 47114 deletions
-32
View File
@@ -375,38 +375,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
delete provider.models[modelId]
}
if (!provider.models["gpt-5.3-codex"]) {
const model = {
id: ModelID.make("gpt-5.3-codex"),
providerID: ProviderID.openai,
api: {
id: "gpt-5.3-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.3 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400_000, input: 272_000, output: 128_000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2026-02-05",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.3-codex"] = model
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {
+174 -166
View File
@@ -14,16 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
resolvePluginEntrypoint,
resolvePluginTarget,
} from "./shared"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -33,9 +25,7 @@ export namespace Plugin {
}
type Loaded = {
item: Config.PluginSpec
spec: string
mod: Record<string, unknown>
row: PluginLoader.Loaded
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern
@@ -88,87 +78,22 @@ export namespace Plugin {
return result
}
async function resolvePlugin(spec: string) {
const parsed = parsePluginSpecifier(spec)
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = errorMessage(cause ?? err)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!target) return
return target
}
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
const spec = Config.pluginSpecifier(item)
if (isDeprecatedPlugin(spec)) return
log.info("loading plugin", { path: spec })
const resolved = await resolvePlugin(spec)
if (!resolved) return
if (pluginSource(spec) === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
const message = errorMessage(err)
log.warn("plugin incompatible", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${spec} skipped: ${message}`,
}).toObject(),
})
return true
})
if (incompatible) return
}
const target = resolved
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
const message = errorMessage(err)
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!entry) return
const mod = await import(entry).catch((err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: spec, target: entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!mod) return
return {
item,
spec,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
if (plugin?.server) {
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
if (plugin) {
await resolvePluginId(
load.row.source,
load.row.spec,
load.row.target,
readPluginId(plugin.id, load.row.spec),
load.row.pkg,
)
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
return
}
for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, Config.pluginOptions(load.item)))
for (const server of getLegacyPlugins(load.row.mod)) {
hooks.push(await server(input, load.row.options))
}
}
@@ -176,76 +101,161 @@ export namespace Plugin {
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const cache = yield* InstanceState.make<State>(
const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = []
yield* Effect.promise(async () => {
const { Server } = await import("../server/server")
const { Server } = yield* Effect.promise(() => import("../server/server"))
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().fetch(...args),
})
const cfg = await Config.get()
const input: PluginInput = {
client,
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input).catch((err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
})
if (init) hooks.push(init)
}
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
}
if (plugins.length) await Config.waitForDependencies()
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
await applyPlugin(load, input, hooks).catch((err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
})
})
}
// Notify plugins of current config
for (const hook of hooks) {
try {
await (hook as any).config?.(cfg)
} catch (err) {
log.error("plugin config hook failed", { error: err })
}
}
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().fetch(...args),
})
const cfg = yield* config.get()
const input: PluginInput = {
client,
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = yield* Effect.tryPromise({
try: () => plugin(input),
catch: (err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
},
}).pipe(Effect.option)
if (init._tag === "Some") hooks.push(init.value)
}
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
}
if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() =>
Promise.all(
plugins.map(async (item) => {
const plan = PluginLoader.plan(item)
if (plan.deprecated) return
log.info("loading plugin", { path: plan.spec })
const resolved = await PluginLoader.resolve(plan, "server")
if (!resolved.ok) {
if (resolved.stage === "missing") {
log.warn("plugin has no server entrypoint", {
path: plan.spec,
message: resolved.message,
})
return
}
const cause =
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
const message = errorMessage(cause)
if (resolved.stage === "install") {
const parsed = parsePluginSpecifier(plan.spec)
log.error("failed to install plugin", {
pkg: parsed.pkg,
version: parsed.version,
error: message,
})
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
}).toObject(),
})
return
}
if (resolved.stage === "compatibility") {
log.warn("plugin incompatible", { path: plan.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${plan.spec} skipped: ${message}`,
}).toObject(),
})
return
}
log.error("failed to resolve plugin server entry", {
path: plan.spec,
error: message,
})
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
const mod = await PluginLoader.load(resolved.value)
if (!mod.ok) {
const message = errorMessage(mod.error)
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
return {
row: mod.value,
}
}),
),
)
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
yield* Effect.tryPromise({
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: load.row.spec, error: message })
return message
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.row.spec}: ${message}`,
}).toObject(),
}),
),
)
}
// Notify plugins of current config
for (const hook of hooks) {
yield* Effect.tryPromise({
try: () => Promise.resolve((hook as any).config?.(cfg)),
catch: (err) => {
log.error("plugin config hook failed", { error: err })
},
}).pipe(Effect.ignore)
}
// Subscribe to bus events, fiber interrupted when scope closes
yield* bus.subscribeAll().pipe(
@@ -269,31 +279,29 @@ export namespace Plugin {
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output) {
if (!name) return output
const state = yield* InstanceState.get(cache)
yield* Effect.promise(async () => {
for (const hook of state.hooks) {
const fn = hook[name] as any
if (!fn) continue
await fn(input, output)
}
})
const s = yield* InstanceState.get(state)
for (const hook of s.hooks) {
const fn = hook[name] as any
if (!fn) continue
yield* Effect.promise(async () => fn(input, output))
}
return output
})
const list = Effect.fn("Plugin.list")(function* () {
const state = yield* InstanceState.get(cache)
return state.hooks
const s = yield* InstanceState.get(state)
return s.hooks
})
const init = Effect.fn("Plugin.init")(function* () {
yield* InstanceState.get(cache)
yield* InstanceState.get(state)
})
return Service.of({ trigger, list, init })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function trigger<
+112 -46
View File
@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
@@ -94,33 +95,91 @@ function pluginSpec(item: unknown) {
return item[0]
}
function parseTarget(item: unknown): Target | undefined {
if (item === "server" || item === "tui") return { kind: item }
if (!Array.isArray(item)) return
if (item[0] !== "server" && item[0] !== "tui") return
if (item.length < 2) return { kind: item[0] }
const opt = item[1]
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
return {
kind: item[0],
opts: opt,
}
function pluginList(data: unknown) {
if (!data || typeof data !== "object" || Array.isArray(data)) return
const item = data as { plugin?: unknown }
if (!Array.isArray(item.plugin)) return
return item.plugin
}
function parseTargets(raw: unknown) {
if (!Array.isArray(raw)) return []
const map = new Map<Kind, Target>()
for (const item of raw) {
const hit = parseTarget(item)
function exportValue(value: unknown): string | undefined {
if (typeof value === "string") {
const next = value.trim()
if (next) return next
return
}
if (!isRecord(value)) return
for (const key of ["import", "default"]) {
const next = value[key]
if (typeof next !== "string") continue
const hit = next.trim()
if (!hit) continue
map.set(hit.kind, hit)
return hit
}
return [...map.values()]
}
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
function exportOptions(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value)) return
const config = value.config
if (!isRecord(config)) return
return config
}
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
const exports = pkg.exports
if (!isRecord(exports)) return
const value = exports[`./${kind}`]
const entry = exportValue(value)
if (!entry) return
return {
opts: exportOptions(value),
}
}
function hasMainTarget(pkg: Record<string, unknown>) {
const main = pkg.main
if (typeof main !== "string") return false
return Boolean(main.trim())
}
function packageTargets(pkg: Record<string, unknown>) {
const targets: Target[] = []
const server = exportTarget(pkg, "server")
if (server) {
targets.push({ kind: "server", opts: server.opts })
} else if (hasMainTarget(pkg)) {
targets.push({ kind: "server" })
}
const tui = exportTarget(pkg, "tui")
if (tui) {
targets.push({ kind: "tui", opts: tui.opts })
}
return targets
}
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
return applyEdits(
text,
modify(text, path, value, {
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
isArrayInsertion: insert,
}),
)
}
function patchPluginList(
text: string,
list: unknown[] | undefined,
spec: string,
next: unknown,
force = false,
): { mode: Mode; text: string } {
const pkg = parsePluginSpecifier(spec).pkg
const rows = list.map((item, i) => ({
const rows = (list ?? []).map((item, i) => ({
item,
i,
spec: pluginSpec(item),
@@ -133,16 +192,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
})
if (!dup.length) {
if (!list) {
return {
mode: "add",
text: patch(text, ["plugin"], [next]),
}
}
return {
mode: "add",
list: [...list, next],
text: patch(text, ["plugin", list.length], next, true),
}
}
if (!force) {
return {
mode: "noop",
list,
text,
}
}
@@ -150,29 +215,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
if (!keep) {
return {
mode: "noop",
list,
text,
}
}
if (dup.length === 1 && keep.spec === spec) {
return {
mode: "noop",
list,
text,
}
}
const idx = new Set(dup.map((item) => item.i))
let out = text
if (typeof keep.item === "string") {
out = patch(out, ["plugin", keep.i], next)
}
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
out = patch(out, ["plugin", keep.i, 0], spec)
}
const del = dup
.map((item) => item.i)
.filter((i) => i !== keep.i)
.sort((a, b) => b - a)
for (const i of del) {
out = patch(out, ["plugin", i], undefined)
}
return {
mode: "replace",
list: rows.flatMap((row) => {
if (!idx.has(row.i)) return [row.item]
if (row.i !== keep.i) return []
if (typeof row.item === "string") return [next]
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
return [[spec, ...row.item.slice(1)]]
}
return [row.item]
}),
text: out,
}
}
@@ -220,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
}
}
const targets = parseTargets(pkg.item.json["oc-plugin"])
const targets = packageTargets(pkg.item.json)
if (!targets.length) {
return {
ok: false,
@@ -289,10 +362,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
}
}
const list: unknown[] =
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
const item = target.opts ? [spec, target.opts] : spec
const out = patchPluginList(list, spec, item, force)
const list = pluginList(data)
const item = target.opts ? ([spec, target.opts] as const) : spec
const out = patchPluginList(text, list, spec, item, force)
if (out.mode === "noop") {
return {
ok: true,
@@ -304,13 +376,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
}
}
const edits = modify(text, ["plugin"], out.list, {
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
})
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
if (write instanceof Error) {
return {
ok: false,
+137
View File
@@ -0,0 +1,137 @@
import { Config } from "@/config/config"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
createPluginEntry,
isDeprecatedPlugin,
resolvePluginTarget,
type PluginKind,
type PluginPackage,
type PluginSource,
} from "./shared"
export namespace PluginLoader {
export type Plan = {
item: Config.PluginSpec
spec: string
options: Config.PluginOptions | undefined
deprecated: boolean
}
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
export function plan(item: Config.PluginSpec): Plan {
const spec = Config.pluginSpecifier(item)
return {
item,
spec,
options: Config.pluginOptions(item),
deprecated: isDeprecatedPlugin(spec),
}
}
export async function resolve(
plan: Plan,
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; message: string }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
} catch (error) {
return {
ok: false,
stage: "install",
error,
}
}
if (!target) {
return {
ok: false,
stage: "install",
error: new Error(`Plugin ${plan.spec} target is empty`),
}
}
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
} catch (error) {
return {
ok: false,
stage: "entry",
error,
}
}
if (!base.entry) {
return {
ok: false,
stage: "missing",
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
}
}
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
} catch (error) {
return {
ok: false,
stage: "compatibility",
error,
}
}
}
return {
ok: true,
value: {
...plan,
source: base.source,
target: base.target,
entry: base.entry,
pkg: base.pkg,
},
}
}
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
mod = await import(row.entry)
} catch (error) {
return {
ok: false,
error,
}
}
if (!mod) {
return {
ok: false,
error: new Error(`Plugin ${row.spec} module is empty`),
}
}
return {
ok: true,
value: {
...row,
mod,
},
}
}
}
+30 -7
View File
@@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = {
id: string
source: Source
@@ -24,6 +31,7 @@ export namespace PluginMeta {
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type State = "first" | "updated" | "same"
@@ -35,7 +43,7 @@ export namespace PluginMeta {
}
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
function storePath() {
@@ -52,11 +60,11 @@ export namespace PluginMeta {
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
function resolvedTarget(target: string) {
@@ -66,7 +74,7 @@ export namespace PluginMeta {
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
@@ -84,7 +92,7 @@ export namespace PluginMeta {
source,
spec,
target,
modified: file ? modifiedAt(file) : undefined,
modified: file ? await modifiedAt(file) : undefined,
}
}
@@ -122,6 +130,7 @@ export namespace PluginMeta {
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
@@ -158,6 +167,20 @@ export namespace PluginMeta {
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...(entry.themes ?? {}),
[name]: theme,
}
await Filesystem.writeJson(file, store)
})
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
+168 -45
View File
@@ -21,20 +21,33 @@ export function parsePluginSpecifier(spec: string) {
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
type PluginMode = "strict" | "detect"
export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm"
export type PluginPackage = {
dir: string
pkg: string
json: Record<string, unknown>
}
function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
if (!isRecord(json.exports)) return false
return `./${kind}` in json.exports
export type PluginEntry = {
spec: string
source: PluginSource
target: string
pkg?: PluginPackage
entry?: string
}
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
export function pluginSource(spec: string): PluginSource {
if (isPathPluginSpec(spec)) return "file"
return "npm"
}
function resolveExportPath(raw: string, dir: string) {
if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
if (raw.startsWith("file://")) return fileURLToPath(raw)
return raw
if (path.isAbsolute(raw)) return raw
return path.resolve(dir, raw)
}
function extractExportValue(value: unknown): string | undefined {
@@ -47,26 +60,92 @@ function extractExportValue(value: unknown): string | undefined {
return undefined
}
export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return target
if (!hasEntrypoint(pkg.json, kind)) return target
const exports = pkg.json.exports
if (!isRecord(exports)) return target
const raw = extractExportValue(exports[`./${kind}`])
if (!raw) return target
function packageMain(pkg: PluginPackage) {
const value = pkg.json.main
if (typeof value !== "string") return
const next = value.trim()
if (!next) return
return next
}
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
const resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
}
return pathToFileURL(next).href
}
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
const exports = pkg.json.exports
if (isRecord(exports)) {
const raw = extractExportValue(exports[`./${kind}`])
if (raw) return resolvePackagePath(spec, raw, kind, pkg)
}
if (kind !== "server") return
const main = packageMain(pkg)
if (!main) return
return resolvePackagePath(spec, main, kind, pkg)
}
function targetPath(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
if (path.isAbsolute(target)) return target
}
async function resolveDirectoryIndex(dir: string) {
for (const name of INDEX_FILES) {
const file = path.join(dir, name)
if (await Filesystem.exists(file)) return file
}
}
async function resolveTargetDirectory(target: string) {
const file = targetPath(target)
if (!file) return
const stat = await Filesystem.stat(file)
if (!stat?.isDirectory()) return
return file
}
async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
const source = pluginSource(spec)
const hit =
pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
if (!hit) return target
const entry = resolvePackageEntrypoint(spec, kind, hit)
if (entry) return entry
const dir = await resolveTargetDirectory(target)
if (kind === "tui") {
if (source === "file" && dir) {
const index = await resolveDirectoryIndex(dir)
if (index) return pathToFileURL(index).href
}
if (source === "npm") return
if (dir) return
return target
}
if (dir && isRecord(hit.json.exports)) {
if (source === "file") {
const index = await resolveDirectoryIndex(dir)
if (index) return pathToFileURL(index).href
}
return
}
return target
}
export function isPathPluginSpec(spec: string) {
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
}
@@ -80,19 +159,21 @@ export async function resolvePathPluginTarget(spec: string) {
return pathToFileURL(file).href
}
const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
if (typeof pkg.main !== "string" || !pkg.main.trim()) {
throw new Error(`Plugin directory ${file} must define package.json main`)
if (await Filesystem.exists(path.join(file, "package.json"))) {
return pathToFileURL(file).href
}
return pathToFileURL(path.resolve(file, pkg.main)).href
const index = await resolveDirectoryIndex(file)
if (index) return pathToFileURL(index).href
throw new Error(`Plugin directory ${file} is missing package.json or index file`)
}
export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return
const engines = pkg.json.engines
const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
if (!hit) return
const engines = hit.json.engines
if (!isRecord(engines)) return
const range = engines.opencode
if (typeof range !== "string") return
@@ -103,10 +184,10 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
return Npm.add(parsed.version === "latest" ? parsed.pkg : `${parsed.pkg}@${parsed.version}`)
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
}
export async function readPluginPackage(target: string) {
export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
const stat = await Filesystem.stat(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
@@ -115,6 +196,20 @@ export async function readPluginPackage(target: string) {
return { dir, pkg, json }
}
export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
const source = pluginSource(spec)
const pkg =
source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
return {
spec,
source,
target,
pkg,
entry,
}
}
export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
@@ -123,27 +218,55 @@ export function readPluginId(id: unknown, spec: string) {
return value
}
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
export function readV1Plugin(
mod: Record<string, unknown>,
spec: string,
kind: PluginKind,
mode: PluginMode = "strict",
) {
const value = mod.default
if (!isRecord(value)) {
if (mode === "detect") return
throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
}
if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") {
throw new TypeError(`Plugin ${spec} has invalid server export`)
}
if (tui !== undefined && typeof tui !== "function") {
throw new TypeError(`Plugin ${spec} has invalid tui export`)
}
if (server !== undefined && tui !== undefined) {
throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
}
if (kind === "server" && server === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with server()`)
}
if (kind === "tui" && tui === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
}
return value
}
export async function resolvePluginId(
source: PluginSource,
spec: string,
target: string,
id: string | undefined,
pkg?: PluginPackage,
) {
if (source === "file") {
if (id) return id
throw new TypeError(`Path plugin ${spec} must export id`)
}
if (id) return id
const pkg = await readPluginPackage(target)
if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
const hit = pkg ?? (await readPluginPackage(target))
if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
}
return pkg.json.name.trim()
}
export function getDefaultPlugin(mod: Record<string, unknown>) {
// A single default object keeps v1 detection explicit and avoids scanning exports.
const value = mod.default
if (!isRecord(value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") return
if (tui !== undefined && typeof tui !== "function") return
if (server === undefined && tui === undefined) return
return value
return hit.json.name.trim()
}