actual-tui-plugins
Sebastian Herrlinger 2026-03-04 22:09:23 +01:00
parent 72c4deb90c
commit 71b960f2d1
6 changed files with 45 additions and 46 deletions

View File

@ -12,8 +12,9 @@ import "@opentui/solid/preload"
import { Config } from "@/config/config"
import { TuiConfig } from "@/config/tui"
import { Log } from "@/util/log"
import { BunProc } from "@/bun"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
import { registerThemes } from "./context/theme"
type Slot = <K extends keyof TuiSlotMap>(props: { name: K } & TuiSlotMap[K]) => JSX.Element | null
@ -22,12 +23,6 @@ function empty<K extends keyof TuiSlotMap>(_props: { name: K } & TuiSlotMap[K])
return null
}
function isRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
return true
}
function isTuiSlotPlugin(value: unknown): value is SolidPlugin<TuiSlotMap, TuiSlotContext> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
@ -99,14 +94,6 @@ export namespace TuiPlugin {
return loaded
}
async function resolve(spec: string) {
if (spec.startsWith("file://")) return spec
const lastAtIndex = spec.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec
const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest"
return BunProc.install(pkg, version)
}
async function load<Renderer>(input: TuiPluginInput<Renderer>) {
const dir = process.cwd()
@ -120,7 +107,7 @@ export namespace TuiPlugin {
for (const item of plugins) {
const spec = Config.pluginSpecifier(item)
log.info("loading tui plugin", { path: spec })
const target = await resolve(spec).catch((error) => {
const target = await resolvePluginTarget(spec).catch((error) => {
log.error("failed to resolve tui plugin", { path: spec, error })
return
})
@ -132,10 +119,7 @@ export namespace TuiPlugin {
})
if (!mod) continue
const seen = new Set<unknown>()
for (const [name, entry] of Object.entries(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
for (const [name, entry] of uniqueModuleEntries(mod)) {
if (!entry || typeof entry !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: spec,

View File

@ -32,6 +32,7 @@ import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
@ -1324,10 +1325,6 @@ export namespace Config {
return candidates[0]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {

View File

@ -8,6 +8,7 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
export namespace TuiConfig {
@ -100,12 +101,6 @@ export namespace TuiConfig {
await Promise.all(deps)
}
function isRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
return true
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}

View File

@ -4,13 +4,13 @@ import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin {
@ -55,25 +55,22 @@ export namespace Plugin {
}
async function resolve(spec: string) {
if (spec.startsWith("file://")) return spec
const lastAtIndex = spec.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec
const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest"
const builtIn = BUILTIN.some((x) => x.startsWith(pkg + "@"))
const installed = await BunProc.install(pkg, version).catch((err) => {
const parsed = parsePluginSpecifier(spec)
const builtIn = BUILTIN.some((x) => x.startsWith(parsed.pkg + "@"))
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
const label = builtIn ? "built-in plugin" : "plugin"
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install ${label} ${pkg}@${version}: ${detail}`,
message: `Failed to install ${label} ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!installed) return
return installed
if (!target) return
return target
}
function isServerPlugin(value: unknown): value is PluginInstance {
@ -108,11 +105,8 @@ export namespace Plugin {
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
const seen = new Set<unknown>()
for (const entry of Object.values(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
// uniqueModuleEntries keeps only the first export for each shared value reference.
for (const [, entry] of uniqueModuleEntries(mod)) {
const server = getServerPlugin(entry)
if (!server) continue
const init = await server(input, Config.pluginOptions(item)).catch((err) => {

View File

@ -0,0 +1,26 @@
import { BunProc } from "@/bun"
export function parsePluginSpecifier(spec: string) {
const at = spec.lastIndexOf("@")
const pkg = at > 0 ? spec.substring(0, at) : spec
const version = at > 0 ? spec.substring(at + 1) : "latest"
return { pkg, version }
}
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (spec.startsWith("file://")) return spec
return BunProc.install(parsed.pkg, parsed.version)
}
export function uniqueModuleEntries(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const entries: [string, unknown][] = []
for (const [name, entry] of Object.entries(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
entries.push([name, entry])
}
return entries
}

View File

@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}