Compare commits

...
54 changed files with 2087 additions and 149 deletions
+11 -3
View File
@@ -966,6 +966,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -975,6 +976,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -5812,7 +5814,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6642,8 +6644,6 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -7450,6 +7450,12 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8156,6 +8162,8 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
@@ -85,10 +85,10 @@ export default Runtime.handler(Commands, (input) =>
},
packages: {
resolve: (spec, install = true) =>
runPromise(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })),
check: (spec) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
npm.check(spec).pipe(Effect.mapError((error) => (error.cause instanceof Error ? error.cause : error))),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
+8
View File
@@ -88,8 +88,16 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
}
export type SessionListInput = {
+13 -1
View File
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -318,7 +320,17 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
+1
View File
@@ -32,6 +32,7 @@ export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Plugin } from "@opencode-ai/schema/plugin"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Provider } from "@opencode-ai/schema/provider"
@@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -462,6 +464,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+20 -2
View File
@@ -16,6 +16,8 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -441,8 +443,8 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
| { id: string; source: PluginSource; revision?: string; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; revision?: string; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -2338,6 +2340,10 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginCheckError = { readonly _tag: "PluginCheckError"; readonly message: string }
export const isPluginCheckError = (value: unknown): value is PluginCheckError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCheckError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2548,6 +2554,18 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
@@ -3,6 +3,7 @@ import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Model } from "@opencode-ai/schema/model"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
@@ -13,6 +14,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Agent).toBe(Agent)
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Plugin).toBe(Plugin)
expect(Client.Session).toBe(Session)
})
+35
View File
@@ -8,6 +8,7 @@ import {
Location,
Model,
OpenCode,
Plugin,
Prompt,
Session,
SessionMessage,
@@ -27,6 +28,40 @@ test("health.get decodes the readiness response", async () => {
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("plugin.check decodes package status and its declared error", async () => {
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
const httpClient = HttpClient.make((request) => {
expect(request.method).toBe("POST")
expect(request.url).toBe("http://localhost:3000/api/plugin/check")
expect(Object.fromEntries(request.urlParams.params)).toEqual({ "location[directory]": "/tmp/project" })
const body =
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
expect(Object.keys(body)).toEqual(["target"])
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
body.target === "missing"
? Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
: Response.json({
location: {
directory: "/tmp/project",
project: { id: "proj_test", directory: "/tmp/project", canonical: "/tmp/project" },
},
data: status,
}),
),
)
})
await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const location = { directory: "/tmp/project" }
expect((yield* client.plugin.check({ target: "fixture-plugin@^1", location })).data).toEqual(status)
const error = yield* client.plugin.check({ target: "missing", location }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Plugin.CheckError)
expect(error.message).toBe("Not a configured plugin source")
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+29 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
import { isPluginCheckError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
@@ -55,6 +55,34 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
expect(Object.keys(client.plugin)).toEqual(["list", "check"])
})
test("plugin.check uses the inspection contract and retains declared errors", async () => {
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
expect(request.method).toBe("POST")
expect(request.url).toBe("http://localhost:3000/api/plugin/check?location%5Bdirectory%5D=%2Ftmp%2Fproject")
const body = await request.json()
expect(Object.keys(body)).toEqual(["target"])
if (body.target === "missing")
return Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
expect(body.target).toBe("fixture-plugin@^1")
return Response.json({ location: { directory: "/tmp/project" }, data: status })
},
})
expect(
(await client.plugin.check({ target: "fixture-plugin@^1", location: { directory: "/tmp/project" } })).data,
).toEqual(status)
const error = await client.plugin
.check({ target: "missing", location: { directory: "/tmp/project" } })
.catch((cause: unknown) => cause)
expect(isPluginCheckError(error)).toBe(true)
if (!isPluginCheckError(error)) throw new Error("Expected PluginCheckError")
expect(error.message).toBe("Not a configured plugin source")
})
test("config.get returns ordered config entries for a location", async () => {
+9 -3
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { CheckError, Event, ID, Info, PackageStatus, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -37,6 +37,7 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly revision?: string
readonly source?: Plugin.Source
}
@@ -119,18 +120,22 @@ const layer = Layer.effect(
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
const failed = {
id: definition.id,
source: definition.source ?? { type: "builtin" },
...(definition.revision === undefined ? {} : { revision: definition.revision }),
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
} satisfies Plugin.Info
nextInventory.push(failed)
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
failed.revision = previous.plugin.revision
if (failed.revision === undefined) delete failed.revision
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -175,6 +180,7 @@ function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
status: "active",
tui: plugin.tui ?? false,
}
+7
View File
@@ -3,6 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { CheckError } from "@opencode-ai/schema/plugin"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
@@ -332,6 +333,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
check: () =>
Effect.fail(
new CheckError({
message: "Plugin package checks are available through an external client or the Plugins dialog.",
}),
),
},
reference: {
list: () => response(reference.list()),
+11 -4
View File
@@ -3,7 +3,7 @@ export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { Effect, Predicate, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
@@ -34,13 +34,16 @@ const Definition = Schema.Struct({
]),
})
const revisions = new WeakMap<object, string | undefined>()
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
const resolved = path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })
const entrypoint = resolved.entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -48,12 +51,16 @@ export const load = Effect.fn("PluginModule.load")(function* (
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const exported = Predicate.hasProperty(mod, "default") && Predicate.isObject(mod.default) ? mod.default : undefined
// Keep the import-time revision on the original export, not copied namespaces or decoded records.
if (exported && !revisions.has(exported)) revisions.set(exported, resolved.revision)
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
revision: exported ? revisions.get(exported) : undefined,
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
@@ -1,6 +1,7 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -9,6 +10,7 @@ import { Context, Effect } from "effect"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.CheckError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+24 -1
View File
@@ -89,6 +89,7 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -136,7 +137,29 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
return Service.of({
flush: ready.await,
check: Effect.fn("PluginSupervisor.check")(function* (target: string) {
yield* ready.await
const inventory = yield* registry.list()
if (!inventory.some((plugin) => plugin.source.type === "package" && plugin.source.package === target)) {
return yield* new Plugin.CheckError({
message: `Plugin package is not in the current server inventory: ${target}`,
})
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) {
return yield* new Plugin.CheckError({ message: `Unsupported plugin package source: ${target}` })
}
return yield* npm.check(target).pipe(
Effect.mapError(
(error) =>
new Plugin.CheckError({
message: `Failed to check plugin package ${target}: ${error.cause instanceof Error ? error.cause.message : error.message}`,
}),
),
)
}),
})
}),
)
@@ -31,7 +31,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
layer: Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
),
deps: [],
})
@@ -313,7 +316,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
return PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -12,7 +12,10 @@ export const promptLocationLayer = Layer.effect(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
check: () => Effect.succeed({ mutable: false }),
}),
) as Layer.Layer<LocationServices>,
),
)
+165 -1
View File
@@ -55,6 +55,62 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, version)
await fs.mkdir(path.join(root, "package"), { recursive: true })
await writePackage(path.join(root, "package"), {
name: "@fixture/registry-plugin",
version,
exports: "./index.js",
})
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
await Bun.$`tar -czf package.tgz package`.cwd(root)
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0", offline: false, requests: 0, tarballRequests: 0 }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
state.requests++
if (state.offline) return new Response("offline", { status: 503 })
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
[...tarballs.keys()].map((version) => [
version,
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
]),
),
})
state.tarballRequests++
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
},
})
return {
state,
tarball: `${server.url}1.0.0.tgz`,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -131,6 +187,7 @@ describe("Npm.add", () => {
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.revision).toBe("1.0.0")
})
test("falls back to the original spec when parsing fails", async () => {
@@ -198,6 +255,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.added.revision).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
@@ -218,6 +276,7 @@ describe("Npm.add", () => {
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.revision).toBe(fixture.commit)
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
@@ -236,11 +295,27 @@ describe("Npm.add", () => {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
const nextCommit = yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
return Bun.$`git -C ${fixture.repository} rev-parse HEAD`.text().then((value) => value.trim())
})
const lock = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(mutable)), "package-lock.json")
const before = yield* Effect.promise(() => Bun.file(lock).bytes())
expect((yield* npm.resolve(mutable)).revision).toBe(fixture.commit)
expect(yield* npm.check(mutable)).toEqual({ installed: fixture.commit, available: nextCommit, mutable: true })
expect(yield* npm.check(pinned)).toEqual({ installed: fixture.commit, available: fixture.commit, mutable: false })
expect(yield* Effect.promise(() => Bun.file(lock).bytes())).toEqual(before)
expect(yield* npm.add(mutable)).toEqual(mutableEntry)
expect(mutableEntry.revision).toBe(fixture.commit)
const uncached = `git+${repository}#HEAD`
expect(yield* npm.check(uncached)).toEqual({ installed: undefined, available: nextCommit, mutable: true })
expect(yield* Effect.promise(() => fs.readdir(path.join(cache, "packages")))).not.toContain(
yield* Effect.promise(() => Npm.cacheKey(uncached)),
)
expect((yield* npm.resolve(uncached)).entrypoint).toBeUndefined()
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
@@ -260,6 +335,8 @@ describe("Npm.add", () => {
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.resolve(mutable)).revision).toBe(second.mutable.revision)
expect((yield* npm.check(mutable).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
@@ -291,5 +368,92 @@ describe("Npm.resolve", () => {
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.revision).toBe("1.0.0")
})
})
describe("Npm.check and revision metadata", () => {
test("checks registry tags, ranges, and exact pins without changing or installing packages", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const range = "@fixture/registry-plugin@^1.0.0"
const pinned = "@fixture/registry-plugin@1.0.0"
const alias = "alias@npm:@fixture/registry-plugin@latest"
const root = await registry.configure(cache, spec)
const rangeRoot = await registry.configure(cache, range)
const pinnedRoot = await registry.configure(cache, pinned)
await registry.configure(cache, alias)
await Effect.gen(function* () {
const npm = yield* Npm.Service
for (const unsupported of [registry.tarball, alias]) {
const error = yield* npm.check(unsupported).pipe(Effect.flip)
expect(error._tag).toBe("NpmInstallFailedError")
expect(error.cause).toEqual(new Error("Package checks only support registry and Git package specs"))
}
expect(registry.state.requests).toBe(0)
expect(registry.state.tarballRequests).toBe(0)
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
const loaded = yield* npm.add(spec)
expect(loaded.revision).toBe("1.0.0")
const files = [
"package.json",
"package-lock.json",
"node_modules/@fixture/registry-plugin/package.json",
"node_modules/@fixture/registry-plugin/index.js",
]
const before = yield* Effect.promise(() =>
Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes())),
)
const requests = registry.state.tarballRequests
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: true })
registry.state.latest = "1.1.0"
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
expect(yield* npm.check(range)).toEqual({ installed: undefined, available: "1.1.0", mutable: true })
expect(yield* npm.check(pinned)).toEqual({ installed: undefined, available: "1.0.0", mutable: false })
expect(yield* npm.add(spec)).toEqual(loaded)
expect(yield* npm.resolve(spec)).toEqual(loaded)
expect(
yield* Effect.promise(() => Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes()))),
).toEqual(before)
expect(registry.state.tarballRequests).toBe(requests)
expect(yield* Effect.promise(() => fs.readdir(rangeRoot))).toEqual([".npmrc"])
expect(yield* Effect.promise(() => fs.readdir(pinnedRoot))).toEqual([".npmrc"])
registry.state.offline = true
expect((yield* npm.check(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect((yield* npm.resolve(spec)).revision).toBe("1.0.0")
expect(yield* npm.add(spec)).toEqual(loaded)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
}, 30_000)
test("leaves unknown installed revisions undefined and prefers the wrapper Git lock", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const commit = "0123456789abcdef0123456789abcdef01234567"
const spec = `git+https://example.com/fixture.git#${commit}`
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
const directory = path.join(root, "node_modules", "fixture")
await fs.mkdir(directory, { recursive: true })
await writePackage(root, { dependencies: { fixture: spec } })
await writePackage(directory, { name: "fixture", exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
expect((yield* npm.add(spec)).revision).toBeUndefined()
const lock = (revision: string) =>
JSON.stringify({
packages: { "node_modules/fixture": { resolved: `git+https://example.com/fixture.git#${revision}` } },
})
yield* Effect.promise(() => Bun.write(path.join(root, "node_modules", ".package-lock.json"), lock(commit)))
expect((yield* npm.resolve(spec)).revision).toBe(commit)
const installed = "f".repeat(40)
yield* Effect.promise(() => Bun.write(path.join(root, "package-lock.json"), lock(installed)))
expect((yield* npm.resolve(spec)).revision).toBe(installed)
yield* Effect.promise(() => fs.rm(directory, { recursive: true }))
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
})
})
+16 -12
View File
@@ -450,19 +450,23 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
for (const revision of ["1.0.0", undefined]) {
yield* plugins.activate([{ ...versioned(previous, revision ?? "unknown"), revision }])
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0" }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
...(revision === undefined ? {} : { revision }),
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
if (revision === undefined) expect((yield* plugins.list())[0]).not.toHaveProperty("revision")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}
}),
)
+248
View File
@@ -0,0 +1,248 @@
import path from "node:path"
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { PackageStatus } from "@opencode-ai/schema/plugin"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Npm } from "@opencode-ai/util/npm"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdir } from "../fixture/tmpdir"
import { it, testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const pluginIt = testEffect(PluginTestLayer)
// Bun.build inside bun test panics on Windows; the Node import boundary is platform-independent.
describe.skipIf(process.platform === "win32")("Node plugin revisions", () => {
it.live("keeps the loaded revision across Node CommonJS namespace wrappers", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-node-revision-")))
const entrypoint = path.join(tmp.path, "plugin.cjs")
const harness = path.join(tmp.path, "harness.ts")
yield* Effect.promise(() =>
fs.symlink(path.join(import.meta.dir, "../../../util/node_modules"), path.join(tmp.path, "node_modules")),
)
yield* Effect.promise(() =>
Bun.write(
harness,
`
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { PluginModule } from ${JSON.stringify(path.join(import.meta.dir, "../../src/plugin/module.ts"))}
import { Npm } from ${JSON.stringify(path.join(import.meta.dir, "../../../util/src/npm.ts"))}
const entrypoint = process.argv[2]
const source = (tui) => 'module.exports = { id: "node-revision-plugin", tui: ' + tui + ', effect: () => {} }'
const load = (revision) => Effect.runPromise(PluginModule.load({ type: "add", target: "inspection-plugin@latest", options: {} }).pipe(
Effect.provideService(Npm.Service, {
add: () => Effect.succeed({ directory: "", entrypoint, revision }),
resolve: () => Effect.die("unused resolve"),
check: () => Effect.die("unused check"),
which: () => Effect.die("unused which"),
}),
))
await writeFile(entrypoint, source(false))
const first = await load("1.0.0")
assert.deepEqual({ tui: first.tui, revision: first.revision }, { tui: false, revision: "1.0.0" })
await writeFile(entrypoint, source(true))
const second = await load("2.0.0")
assert.deepEqual({ tui: second.tui, revision: second.revision }, { tui: false, revision: "1.0.0" })
`,
),
)
const result = yield* Effect.promise(() =>
Bun.build({
entrypoints: [harness],
outdir: tmp.path,
naming: "[name].mjs",
target: "node",
conditions: ["node"],
plugins: [
{
name: "externalize-dependencies",
setup(build) {
build.onResolve({ filter: /^[^.#/]/ }, (args) =>
args.path.startsWith("@opencode-ai/") ? undefined : { path: args.path, external: true },
)
},
},
],
}),
)
expect(result.success).toBe(true)
const child = Bun.spawn(["node", path.join(tmp.path, "harness.mjs"), entrypoint], {
cwd: tmp.path,
env: {
PATH: process.env.PATH,
HOME: tmp.path,
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
XDG_DATA_HOME: path.join(tmp.path, "data"),
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
XDG_STATE_HOME: path.join(tmp.path, "state"),
},
stdout: "pipe",
stderr: "pipe",
})
yield* Effect.addFinalizer(() => Effect.sync(() => child.kill()))
expect(
yield* Effect.promise(() =>
Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]),
),
).toEqual([0, expect.any(String), expect.any(String)])
}),
)
})
pluginIt.live("keeps loaded revision tied to cached module code rather than refreshed disk metadata", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-revision-")))
const entrypoint = path.join(tmp.path, "plugin.ts")
const source = (tui: boolean) =>
`import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))}\nexport default { id: "revision-plugin", tui: ${tui}, effect: () => Effect.void }\n`
yield* Effect.promise(() => Bun.write(entrypoint, source(false)))
const npm = yield* Npm.Service
const plugins = yield* Plugin.Service
const operation = { type: "add" as const, target: "inspection-plugin@latest", options: {} }
const load = (revision: string, options: Record<string, unknown>) =>
PluginModule.load({ ...operation, options }).pipe(
Effect.provideService(Npm.Service, {
...npm,
add: () => Effect.succeed({ directory: tmp.path, entrypoint, revision }),
}),
)
const first = yield* load("1.0.0", {})
yield* plugins.activate([first])
expect(first.version).toBe(JSON.stringify(operation))
expect(first.source).toEqual({ type: "package", package: operation.target })
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* Effect.promise(() => Bun.write(entrypoint, source(true)))
const second = yield* load("2.0.0", { changed: true })
expect(second.tui).toBe(false)
expect(second.revision).toBe("1.0.0")
yield* plugins.activate([second])
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* plugins.activate([{ ...second, version: "failed", effect: () => Effect.die("setup failed") }])
expect((yield* plugins.list())[0]).toMatchObject({ status: "failed", revision: "1.0.0" })
expect(Plugin.PackageStatus).toBe(PackageStatus)
}),
)
pluginIt.effect("rejects in-process checks in Effect and Promise setup without waiting for activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{
id: "effect-check",
version: "1",
effect: (host) =>
host.plugin.check({ target: "inspection-plugin@latest" }).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() =>
expect(error).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("external client"),
}),
),
),
Effect.orDie,
Effect.asVoid,
),
},
{
...PluginPromise.fromPromise({
id: "promise-check",
setup: async (host) => {
await expect(host.plugin.check({ target: "inspection-plugin@latest" })).rejects.toThrow("external client")
},
}),
version: "1",
},
])
expect((yield* plugins.list()).map((plugin) => plugin.status)).toEqual(["active", "active"])
}),
)
it.live("checks configured package sources without installing, writing config, or updating plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-")))
const target = "inspection-plugin@latest"
const unsupported = "https://example.com/plugin.tgz"
const config = JSON.stringify({ plugins: ["-*", target, unsupported] })
const configPath = path.join(tmp.path, "opencode.json")
yield* Effect.promise(() => Bun.write(configPath, config))
let adds = 0
let checks = 0
let fail = false
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Bus.node]), [
[Global.node, tempGlobalLayer],
[
Npm.node,
Layer.succeed(Npm.Service, {
add: () => {
adds++
return Effect.fail(new Npm.InstallFailedError({ dir: tmp.path }))
},
resolve: () => Effect.succeed({ directory: tmp.path }),
which: () => Effect.undefined,
check: () => {
checks++
if (fail)
return Effect.fail(
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
)
return Effect.succeed({ available: "2.0.0", mutable: true })
},
}),
],
]),
)
const locations = yield* LocationServiceMap.Service.pipe(Effect.provide(context))
const bus = yield* Bus.Service.pipe(Effect.provide(context))
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
yield* supervisor.flush
const inventory = yield* plugins.list()
const initialAdds = adds
let updates = 0
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Plugin.Event.Updated.type) updates++
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
expect(yield* supervisor.check(target)).toEqual({ available: "2.0.0", mutable: true })
expect(yield* supervisor.check("not-configured@latest").pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("server inventory"),
})
expect(yield* supervisor.check(unsupported).pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: `Unsupported plugin package source: ${unsupported}`,
})
expect(checks).toBe(1)
fail = true
expect(yield* supervisor.check(target).pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("registry unavailable"),
})
expect(adds).toBe(initialAdds)
expect(yield* plugins.list()).toEqual(inventory)
expect(updates).toBe(0)
expect(yield* Effect.promise(() => Bun.file(configPath).text())).toBe(config)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
}),
)
+1
View File
@@ -38,6 +38,7 @@ const npmLayer = Layer.succeed(
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
}),
)
+1
View File
@@ -103,6 +103,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
check: () => Effect.die("unused plugin.check"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
@@ -25,6 +25,7 @@ function npmEntrypoint(entrypoint?: string) {
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
}
@@ -17,6 +17,7 @@ const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
const addPlugin = Effect.fn(function* () {
+4 -1
View File
@@ -83,7 +83,10 @@ const locations = Layer.effect(
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
check: () => Effect.succeed({ mutable: false }),
}),
),
)
}),
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -390,6 +390,7 @@ const layer = Layer.unwrap(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
check: () => Effect.succeed({ mutable: false }),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
+1 -1
View File
@@ -38,7 +38,7 @@ const skills = Layer.mergeAll(
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
+3 -1
View File
@@ -127,7 +127,9 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
),
deps: [
Config.node,
+3 -1
View File
@@ -100,7 +100,9 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
+1
View File
@@ -289,6 +289,7 @@ export function fromPromise(plugin: Plugin) {
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
check: adaptApiMethod(PluginEndpoints["plugin.check"], host.plugin.check),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
+153
View File
@@ -486,6 +486,123 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
+17
View File
@@ -19,6 +19,23 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Plugin.PackageStatus),
error: Plugin.CheckError.annotate({ httpApiStatus: 400 }),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin package",
description:
"Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
+15
View File
@@ -19,12 +19,14 @@ export const Info = Schema.Union([
Schema.Struct({
id: ID,
source: Source,
revision: optional(Schema.String),
status: Schema.Literal("active"),
tui: Schema.Boolean,
}),
Schema.Struct({
id: ID.pipe(optional),
source: Source,
revision: optional(Schema.String),
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
@@ -32,6 +34,19 @@ export const Info = Schema.Union([
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export const PackageStatus = Schema.Struct({
installed: optional(Schema.String),
available: optional(Schema.String),
mutable: Schema.Boolean,
}).annotate({ identifier: "Plugin.PackageStatus" })
export interface PackageStatus extends Schema.Schema.Type<typeof PackageStatus> {}
export class CheckError extends Schema.TaggedError<CheckError>()(
"PluginCheckError",
{ message: Schema.String },
{ identifier: "PluginCheckError" },
) {}
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("plugin inspection omits absent revisions", () => {
expect(
Schema.encodeSync(Plugin.PackageStatus)({ installed: undefined, available: undefined, mutable: true }),
).toEqual({ mutable: true })
for (const status of ["active", "failed"] as const) {
const info = {
id: Plugin.ID.make("example"),
source: { type: "package" as const, package: "example@latest" },
status,
error: "setup failed",
tui: false,
revision: undefined,
}
expect(Schema.encodeSync(Plugin.Info)(info)).not.toHaveProperty("revision")
expect(Schema.encodeSync(Plugin.Info)({ ...info, revision: "1.2.3" })).toHaveProperty("revision", "1.2.3")
}
})
test("plugin inspection contracts have stable identifiers and readable errors", () => {
expect(Plugin.PackageStatus.ast.annotations?.identifier).toBe("Plugin.PackageStatus")
expect(Plugin.CheckError.ast.annotations?.identifier).toBe("PluginCheckError")
const error = new Plugin.CheckError({ message: "Plugin package is not configured: example" })
expect(error.message).toBe("Plugin package is not configured: example")
expect(Schema.encodeSync(Plugin.CheckError)(error)).toEqual({
_tag: "PluginCheckError",
message: error.message,
})
})
+1 -1
View File
@@ -47,7 +47,7 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
return { flush: release.open.pipe(Effect.andThen(plugins.flush)), check: plugins.check }
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
+10 -5
View File
@@ -1,13 +1,18 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
handlers.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
),
handlers
.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", ({ payload }) =>
response(PluginSupervisor.Service.use((supervisor) => supervisor.check(payload.target))),
),
)
@@ -50,6 +50,7 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.succeed(PluginSupervisor.Service, {
check: () => Effect.succeed({ mutable: false }),
flush: Effect.sync(() => {
flushes++
}),
+76
View File
@@ -0,0 +1,76 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
it.live("plugin check honors Location query, omits unknown revisions, and returns readable 400 errors", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-http-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
const target = "inspection-plugin@latest"
const config = JSON.stringify({ plugins: ["-*", target] })
yield* Effect.promise(() => Bun.write(path.join(project, "opencode.json"), config))
let fail = false
let checks = 0
const handler = yield* ServerFetch.make(
{ database: { path: ":memory:" }, config: { directory: global }, fs: { filewatcher: false } },
{
overrides: [
[
Npm.node,
Layer.succeed(Npm.Service, {
add: () => Effect.fail(new Npm.InstallFailedError({ dir: tmp.path })),
resolve: () => Effect.succeed({ directory: tmp.path }),
which: () => Effect.undefined,
check: () => {
checks++
if (fail)
return Effect.fail(
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
)
return Effect.succeed({ installed: undefined, available: "2.0.0", mutable: true })
},
}),
],
],
},
)
const request = (directory: string, target: string) =>
Effect.promise(async () => {
const url = new URL("http://opencode.local/api/plugin/check")
url.searchParams.set("location[directory]", directory)
const response = await handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
}),
)
const body: unknown = await response.json()
return { status: response.status, body }
})
const result = yield* request(project, target)
expect(result).toMatchObject({
status: 200,
body: { location: { directory: project }, data: { available: "2.0.0", mutable: true } },
})
expect(result).not.toHaveProperty("body.data.installed")
expect(yield* request(global, target)).toMatchObject({
status: 400,
body: { _tag: "PluginCheckError", message: expect.stringContaining("server inventory") },
})
expect(checks).toBe(1)
fail = true
expect(yield* request(project, target)).toMatchObject({
status: 400,
body: { _tag: "PluginCheckError", message: expect.stringContaining("registry unavailable") },
})
expect(yield* Effect.promise(() => Bun.file(path.join(project, "opencode.json")).text())).toBe(config)
}),
)
+1
View File
@@ -269,6 +269,7 @@ export const Definitions = {
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.check": keybind("ctrl+r", "Check for plugin updates"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
@@ -1,10 +1,15 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import type { ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { localSource } from "../../plugin/discovery"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -17,6 +22,8 @@ type Entry =
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
readonly revision?: string
readonly builtin?: boolean
}
export function PluginsDialog(props: {
@@ -25,18 +32,28 @@ export function PluginsDialog(props: {
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [busy, setBusy] = createSignal(false)
const [list, setList] = createSignal<DialogSelectRef<string>>()
const [filter, setFilter] = createSignal("")
const [detail, setDetail] = createSignal<string>()
const [errorDetail, setErrorDetail] = createSignal(false)
const [errors, setErrors] = createSignal<Record<string, string | undefined>>({})
const [checks, setChecks] = props.context.storage.memory("checks", {
initial: { packages: {} as Record<string, PackageStatus> },
})
const [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
const [server, { refetch }] = createResource(
() => (props.server ? undefined : props.context.data.location.default()),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
onCleanup(props.context.data.on("server.connected", () => void refetch()))
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
const registered = props.plugins.registered()
const builtins: Entry[] = registered
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map((plugin) => ({
key: `tui:${plugin.id}`,
@@ -44,6 +61,7 @@ export function PluginsDialog(props: {
id: plugin.id,
target: plugin.id,
status: plugin.active ? ("active" as const) : ("inactive" as const),
builtin: true,
}))
const external: Entry[] = props.plugins
.list()
@@ -55,6 +73,7 @@ export function PluginsDialog(props: {
target: plugin.target,
status: plugin.status,
error: plugin.status === "failed" ? plugin.error : undefined,
revision: registered.find((item) => item.id === plugin.id)?.revision,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
@@ -71,9 +90,68 @@ export function PluginsDialog(props: {
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const owner = (entry: Entry | undefined) => {
if (!entry) return undefined
if (entry.runtime === "server")
return entry.plugin.source.type === "package"
? { runtime: "server" as const, target: entry.plugin.source.package }
: undefined
if (entry.builtin || localSource(entry.target, ".")) return undefined
const companion = (props.server?.() ?? server() ?? []).some(
(plugin) => plugin.source.type === "package" && plugin.source.package === entry.target,
)
return { runtime: companion ? "server" : "tui", target: entry.target }
}
const checkKey = (entry: Entry | undefined) => {
const target = owner(entry)
return JSON.stringify([
target?.runtime,
target?.runtime === "server" ? props.context.data.location.default() : undefined,
target?.target,
])
}
const checked = (entry: Entry | undefined): PackageStatus | undefined => checks.packages[checkKey(entry)]
const revision = (entry: Entry) => (entry.runtime === "server" ? entry.plugin.revision : entry.revision)
const available = (entry: Entry) => {
const value = checked(entry)
return value?.mutable && value.available && value.available !== (revision(entry) ?? value.installed)
}
const entryError = (entry: Entry | undefined) => {
const load = pluginError(entry)
const check = errors()[checkKey(entry)]
return load && check ? `Load error:\n${load}\n\nCheck error:\n${check}` : (check ?? load)
}
const check = (entry: Entry | undefined) => {
const target = owner(entry)
if (locked() || !entry || !target) return
const key = checkKey(entry)
setLocked(true)
setBusy(true)
setErrors((items) => ({ ...items, [key]: undefined }))
const task =
target.runtime === "server"
? props.context.client.plugin
.check({ target: target.target, location: props.context.data.location.default() })
.then((result) => result.data)
: props.plugins.check(target.target)
void task
.then((result) =>
setChecks((draft) => {
draft.packages[key] = result
}),
)
.catch((cause) => {
setErrors((items) => ({ ...items, [key]: errorMessage(cause) }))
props.context.ui.toast.show({ variant: "error", message: "Could not check plugin updates; view details." })
})
.finally(() => {
setBusy(false)
setLocked(false)
})
}
const options = createMemo(() =>
entries().map(
(entry): DialogSelectOption<string> => ({
@@ -81,11 +159,18 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
footer: entryError(entry)
? "failed"
: available(entry)
? "↑ update"
: checked(entry)?.mutable === false
? "pinned"
: status(entry) === "active"
? undefined
: status(entry),
footerColor: entryError(entry)
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
@@ -95,10 +180,10 @@ export function PluginsDialog(props: {
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === list()?.selected?.value))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return
if (entry?.runtime !== "tui" || !entry.id) return undefined
return entry
})
const toggleTitle = createMemo(() => {
@@ -125,10 +210,18 @@ export function PluginsDialog(props: {
.finally(() => setLocked(false))
}
const detailEntry = createMemo(() => entries().find((entry) => entry.key === detail()))
const back = () => {
setDetail()
setErrorDetail(false)
dialog.setSize("medium")
}
let scroll: ScrollBoxRenderable | undefined
return (
<box>
<Show
when={detail()}
when={detailEntry()}
fallback={
<DialogSelect
title="Plugins"
@@ -136,45 +229,133 @@ export function PluginsDialog(props: {
current={initial()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
initialFilter={filter()}
onFilter={setFilter}
ref={setList}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
setInitial(option.value)
setDetail(option.value)
}}
actions={
focusedTui()
actions={[
...(focusedTui() && !busy()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
onTrigger: (option: DialogSelectOption<string>) =>
toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []
}
: []),
...(owner(focusedEntry()) && !busy()
? [
{
title: "check",
command: "plugins.check",
onTrigger: (option: DialogSelectOption<string>) =>
check(entries().find((entry) => entry.key === option.value)),
},
]
: []),
]}
footerHints={!busy() && dimensions().width >= 60 ? [{ title: "enter", label: "details" }] : []}
footer={
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
</text>
<Show when={busy()}>
<text fg={props.context.theme.text.subdued}>Checking...</text>
</Show>
}
/>
}
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
<Show
when={errorDetail()}
fallback={
<DialogSelect
title={label(entry(), props.context)}
renderFilter={false}
locked={locked()}
titleView={
<box flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} gap={1}>
<text fg={props.context.theme.text.default} truncate>
<b>{label(entry(), props.context)}</b>
</text>
<scrollbox
ref={(value) => {
scroll = value
}}
width="100%"
height={Math.min(10, Math.max(2, Math.floor((dimensions().height * 3) / 4) - 8))}
scrollbarOptions={{ visible: false }}
>
<text width="100%" fg={props.context.theme.text.subdued} wrapMode="word">
{[
`Runtime ${entry().runtime === "server" ? "Server" : "This terminal"}`,
`Status ${status(entry())}`,
`Source ${pluginSource(entry(), props.context)}`,
`Scope ${owner(entry())?.runtime === "server" || entry().runtime === "server" ? `Server: ${props.context.ui.format.path(props.context.data.location.default().directory)}` : "This terminal"}`,
`Loaded ${revision(entry()) ?? "Unknown"}`,
`Installed ${owner(entry()) ? (checked(entry()) ? (checked(entry())?.installed ?? "Unknown") : "Not checked") : "Not applicable"}`,
`Available ${owner(entry()) ? (checked(entry()) ? (checked(entry())?.available ?? "Unknown") : "Not checked") : "Not applicable"}${checked(entry())?.mutable === false ? " (pinned)" : ""}`,
owner(entry())
? owner(entry())?.runtime === "server"
? "Restart the server, then reopen the TUI to apply updates."
: "Select a newer package spec in cli.json, then reopen the TUI."
: pluginSource(entry(), props.context) === "builtin"
? "Updates with OpenCode itself."
: "Local/SDK source; no package check.",
].join("\n")}
</text>
</scrollbox>
</box>
}
options={[
...(owner(entry()) ? [{ title: "Check for updates", value: "check" }] : []),
...(entryError(entry()) ? [{ title: "View error details", value: "error" }] : []),
{ title: "Back to plugins", value: "back" },
]}
onSelect={(option) => {
if (option.value === "check") return check(entry())
if (option.value === "error") return setErrorDetail(true)
back()
}}
bindings={[
{ bind: "escape", title: "Back", group: "Dialog", run: back },
{ bind: "pageup", title: "Scroll details up", group: "Dialog", run: () => scroll?.scrollBy(-5) },
{ bind: "pagedown", title: "Scroll details down", group: "Dialog", run: () => scroll?.scrollBy(5) },
...(owner(entry())
? [{ id: "plugins.check", title: "Check for updates", group: "Dialog", run: () => check(entry()) }]
: []),
...(entry().runtime === "tui"
? [{ id: "plugins.toggle", title: "Toggle plugin", group: "Dialog", run: () => toggle(entry()) }]
: []),
]}
footer={
<text fg={props.context.theme.text.subdued}>
{busy()
? "Checking..."
: entryError(entry())
? "Check/load failed; view error."
: available(entry())
? "↑ New revision available."
: checked(entry())?.mutable === false
? "Pinned source."
: "PgUp/PgDn scroll details"}
</text>
}
/>
}
>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={entryError(entry()) ?? "Unknown plugin error"}
context={`Status: ${status(entry())}\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setErrorDetail(false)
dialog.setSize("medium")
}}
/>
</Show>
)}
</Show>
</box>
@@ -187,7 +368,7 @@ function label(entry: Entry, context: Plugin.Context) {
}
function pluginSource(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.target
if (entry.runtime === "tui") return entry.builtin ? "builtin" : entry.target
return source(entry.plugin, context)
}
+40 -6
View File
@@ -1,4 +1,5 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
@@ -33,7 +34,8 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
readonly resolve: (spec: string, install?: boolean) => Promise<{ entrypoint?: string; revision?: string }>
readonly check: (spec: string) => Promise<PackageStatus>
}
type State =
@@ -45,6 +47,7 @@ type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
readonly revision?: string
}
type Value = {
@@ -60,6 +63,7 @@ type Value = {
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
readonly check: (target: string) => Promise<PackageStatus>
}
type Registration = {
@@ -67,6 +71,7 @@ type Registration = {
source: RegisteredPlugin["source"]
target?: string
version: string
revision?: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -76,10 +81,14 @@ type Registration = {
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "revision" | "options"> & {
enabled: boolean
}
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
// Re-importing the same specifier can return cached code even if its package changed on disk.
const importedRevisions = new WeakMap<object, string | undefined>()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
@@ -334,6 +343,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
source: "external",
target,
version: resolved.version,
revision: resolved.revision,
options,
enabled: true,
})
@@ -534,6 +544,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
id,
source: plugin.source,
active: plugin.active,
revision: plugin.revision,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
@@ -542,6 +553,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
check: async (target) => {
if (!store.states.some((state) => state.target === target) || localSource(target, directory))
throw new Error(`Not a configured package plugin: ${target}`)
if (serverPlugins().some((plugin) => plugin.source.package === target))
return (await client.api.plugin.check({ target, location: data.location.default() })).data
return props.packages.check(target)
},
}}
>
{props.children}
@@ -580,8 +598,16 @@ async function resolvePlugin(
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
return {
status: "unchanged" as const,
plugin: previous.plugin,
version: previous.version,
revision: previous.revision,
}
const resolved = local
? { entrypoint: await resolveLocal(local), revision: undefined }
: await packages.resolve(spec, install)
const entrypoint = resolved.entrypoint
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
@@ -589,7 +615,7 @@ async function resolvePlugin(
while (true) {
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
return { status: "unchanged" as const, plugin: previous.plugin, version, revision: previous.revision }
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
@@ -601,7 +627,13 @@ async function resolvePlugin(
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
if (!local && !importedRevisions.has(mod)) importedRevisions.set(mod, resolved.revision)
return {
status: "loaded" as const,
plugin: mod.default,
version,
revision: local ? undefined : importedRevisions.get(mod),
}
}
}
@@ -611,6 +643,7 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: snapshotOptions(item.options),
active: false,
routes: {},
@@ -626,6 +659,7 @@ function toDesired(item: Registration): Desired {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: item.options,
enabled: item.active,
}
+26 -16
View File
@@ -17,6 +17,7 @@ export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
placeholder?: string
initialFilter?: string
footer?: JSX.Element
emptyView?: JSX.Element
noMatchView?: JSX.Element
@@ -93,6 +94,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
}
@@ -111,7 +113,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
filter: props.initialFilter ?? "",
input: "keyboard" as "keyboard" | "mouse",
})
const [focusedAction, setFocusedAction] = createSignal<number>()
@@ -305,21 +307,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
}),
on(
[() => store.filter, () => props.current],
([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
},
{ defer: Boolean(props.initialFilter) },
),
)
function move(direction: number) {
@@ -526,6 +532,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
@@ -626,6 +635,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
value={props.initialFilter ?? ""}
onInput={(e) => {
if (props.locked) return
batch(() => {
+13 -13
View File
@@ -32,7 +32,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -108,7 +108,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -182,7 +182,7 @@ test("session title generated while an untitled session is loading remains visib
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -274,7 +274,7 @@ test("automatic rename refreshes the displayed title before settling, even witho
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
@@ -354,7 +354,7 @@ test("session startup prompt is submitted exactly once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -435,7 +435,7 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -509,7 +509,7 @@ test("error investigations repeatedly seed editable home drafts without creating
get: async () => ({ animations: false, keybinds: { "mcp.list": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
@@ -590,7 +590,7 @@ test("shows jump to latest after scrolling one line above the final message", as
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -678,7 +678,7 @@ test("new session inherits the active session model", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -768,7 +768,7 @@ test("keeps the prompt display stable while a new location catalog loads", async
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -849,7 +849,7 @@ test("configured app bindings execute settings and permission commands", async (
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -903,7 +903,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -1015,7 +1015,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
+2 -2
View File
@@ -59,7 +59,7 @@ test.each([70, 120])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -184,7 +184,7 @@ test.each(["first", "second"])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -0,0 +1,47 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useConfig } from "../../src/config"
import { useLocation } from "../../src/context/location"
import { useRoute } from "../../src/context/route"
import { usePlugin } from "../../src/plugin/context"
export const probe = {
setups: 0,
cleanups: 0,
plugins: (): ReturnType<typeof usePlugin> => {
throw new Error("Plugin probe not mounted")
},
config: (): ReturnType<typeof useConfig> => {
throw new Error("Plugin probe not mounted")
},
location: (): ReturnType<typeof useLocation> => {
throw new Error("Plugin probe not mounted")
},
navigate: (_directory: string): void => {
throw new Error("Plugin probe not mounted")
},
}
export default Plugin.define({
id: "fixture.inspection",
setup(context) {
if (context.options.fail) throw new Error("Fixture setup failed")
probe.setups++
context.ui.slot({
append: "app",
render: () => {
const plugins = usePlugin()
const config = useConfig()
const location = useLocation()
const route = useRoute()
probe.plugins = () => plugins
probe.config = () => config
probe.location = () => location
probe.navigate = (directory) => route.navigate({ type: "home", location: { directory } })
return null
},
})
return () => {
probe.cleanups++
}
},
})
+4 -3
View File
@@ -7,6 +7,7 @@ import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import type { PackageResolver } from "../src/plugin/context"
function lifecyclePluginSource(marker: string, id: string, version: string) {
return `
@@ -57,7 +58,7 @@ async function bootApp(
directory: string,
options?: {
plugins?: unknown[]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
resolve?: PackageResolver["resolve"]
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -91,7 +92,7 @@ async function bootApp(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: options?.resolve ?? (async () => undefined) },
packages: { resolve: options?.resolve ?? (async () => ({})), check: async () => ({ mutable: false }) },
args: {},
log: () => {},
}).pipe(
@@ -128,7 +129,7 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
],
resolve: async (spec, install) => {
resolutions.push({ spec, install })
return pathToFileURL(entrypoint).href
return { entrypoint: pathToFileURL(entrypoint).href, revision: "1.0.0" }
},
})
@@ -0,0 +1,347 @@
import { expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { Global } from "@opencode-ai/util/global"
import { InputRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import path from "node:path"
import { pathToFileURL } from "node:url"
import type { Info } from "../src/config"
import type { PackageResolver } from "../src/plugin/context"
import { probe } from "./fixture/plugin-inspection"
import { tmpdir } from "./fixture/fixture"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
const target = "fixture-plugin@latest"
const entrypoint = new URL("./fixture/plugin-inspection.tsx", import.meta.url).href
async function boot(
directory: string,
options: {
width?: number
kittyKeyboard?: boolean
plugins?: Info["plugins"]
server?: () => PluginInfo[]
check?: PackageResolver["check"]
resolve?: PackageResolver["resolve"]
} = {},
) {
const app = await createTestRenderer({
width: options.width ?? 100,
height: 24,
useThread: false,
kittyKeyboard: options.kittyKeyboard ?? true,
})
const location = { directory, project: { id: "proj_fixture", directory, canonical: directory } }
const events = createEventStream()
const requests: { path: string; directory: string | null; target: string }[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname.startsWith("/api/plugin/")) {
const body = await request.json()
requests.push({ path: url.pathname, directory: url.searchParams.get("location[directory]"), target: body.target })
return json({ location, data: { installed: "9.0.0", available: "10.0.0", mutable: true } })
}
if (url.pathname === "/api/plugin") {
return json({ location, data: options.server?.() ?? [] })
}
if (url.pathname === "/api/location")
return json({ ...location, directory: url.searchParams.get("location[directory]") ?? directory })
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const config: Info = { animations: false, plugins: options.plugins ?? [target], keybinds: { "plugins.list": "f6" } }
const cwd = process.cwd()
process.chdir(directory)
probe.setups = 0
probe.cleanups = 0
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => structuredClone(config),
update: async (update) => {
update(config)
return structuredClone(config)
},
},
packages: {
resolve: options.resolve ?? (async () => ({ entrypoint, revision: "1.0.0" })),
check: options.check ?? (async () => ({ installed: "1.0.0", available: "2.0.0", mutable: true })),
},
terminalHandoff: async () => ({ renderer: app.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(
Effect.provide(
Global.layerWith(
Object.fromEntries(
["home", "config", "data", "cache", "state", "tmp", "bin", "log", "repos"].map((name) => [
name,
path.join(directory, name),
]),
),
),
),
Effect.provide(FileSystem.layerNoop({})),
),
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Ask anything"), { maxPasses: 100 })
return {
...app,
events,
requests,
async [Symbol.asyncDispose]() {
app.renderer.destroy()
await task.finally(async () => {
process.chdir(cwd)
events.disconnect()
await server.stop(true)
})
},
}
}
async function open(app: Awaited<ReturnType<typeof boot>>, search = "fixture.inspection") {
app.mockInput.pressKey("F6")
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
await app.mockInput.typeText(search)
await app.waitForFrame((frame) => frame.includes(search) && !frame.includes("Search"))
}
test("explicit checks are read-only, retain loaded snapshots, and preserve toggle/filter behavior", async () => {
await using tmp = await tmpdir()
const checked: string[] = []
const resolved: string[] = []
let disk = "1.0.0"
await using app = await boot(tmp.path, {
resolve: async (spec) => {
resolved.push(spec)
return { entrypoint, revision: disk }
},
check: async (spec) => {
checked.push(spec)
return { installed: disk, available: "3.0.0", mutable: true }
},
})
await open(app)
expect(checked).toEqual([])
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Loaded") && frame.includes("Check for updates"))
expect(details).toContain("1.0.0")
expect(details).not.toMatch(/Update package|Reload installed|Enable in this terminal/)
disk = "2.0.0"
app.mockInput.pressKey("r", { ctrl: true })
const result = await app.waitForFrame((frame) => frame.includes("New revision available"))
expect(result).toMatch(/Loaded\s+1\.0\.0/)
expect(result).toMatch(/Installed\s+2\.0\.0/)
expect(checked).toEqual([target])
expect(resolved).toEqual([target])
expect([probe.setups, probe.cleanups]).toEqual([1, 0])
expect(app.requests).toEqual([])
await expect(probe.plugins().check("unconfigured-plugin")).rejects.toThrow("Not a configured package")
await expect(probe.plugins().check("opencode.plugins")).rejects.toThrow("Not a configured package")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("↑ update") && frame.includes("disable"))
expect(app.renderer.currentFocusedEditor?.plainText).toBe("fixture.inspection")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("enable"))
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.revision,
).toBe("1.0.0")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("disable"))
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { flag: true } }]
})
await app.waitFor(() => probe.setups === 3)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.revision,
).toBe("1.0.0")
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { fail: true } }]
})
await app.waitFor(() =>
probe
.plugins()
.list()
.some((item) => item.status === "failed"),
)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection"),
).toMatchObject({ active: true, revision: "1.0.0" })
})
test.each([40, 100])("pinned checks, busy/error details, and CLI spec guidance at width %s", async (width) => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<PackageStatus>()
let calls = 0
await using app = await boot(tmp.path, {
width,
check: () => {
calls++
return pending.promise
},
})
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Checking..."))
app.mockInput.pressKey("r", { ctrl: true })
expect(calls).toBe(1)
pending.resolve({ available: "2.0.0", mutable: false })
await app.waitForFrame((frame) => frame.includes("pinned"))
app.mockInput.pressEnter()
const frame = await app.waitForFrame((frame) => frame.includes("Pinned source."))
expect(frame).not.toContain("New revision available")
expect(frame).not.toMatch(/Update package|Reload installed/)
if (width === 100) expect(frame).toMatch(/Installed\s+Unknown/)
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => frame.includes("cli.json"))
expect(app.requests).toEqual([])
})
test.each(["active", "failed", "without-tui"])(
"late %s server inventory uses the default check scope and shares results",
async (kind) => {
await using tmp = await tmpdir()
let server: PluginInfo[] = []
await using app = await boot(tmp.path, {
server: () => server,
check: async () => {
throw new Error("Companion must check on server")
},
})
probe.navigate("/projects/active-session")
await app.waitFor(() => probe.location().current?.directory === "/projects/active-session")
await open(app)
const info = {
id: "fixture.server",
source: { type: "package" as const, package: target },
tui: kind === "active",
revision: "9.0.0",
}
server = [
kind === "failed" ? { ...info, status: "failed", error: "Server fixture failed" } : { ...info, status: "active" },
]
app.events.emit({ id: "evt_fixture", created: Date.now(), type: "plugin.updated", data: {} })
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.server")
await app.waitForFrame((frame) => frame.includes("fixture.server") && frame.includes("Server"))
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.inspection")
await app.waitForFrame((frame) => frame.includes("fixture.inspection") && !frame.includes("fixture.server"))
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("↑ update"))
expect(app.requests).toEqual([{ path: "/api/plugin/check", directory: tmp.path, target }])
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Loaded") && frame.includes("Installed"))
expect(details).toMatch(/Loaded\s+1\.0\.0/)
expect(details).toMatch(/Installed\s+9\.0\.0/)
app.mockInput.pressEscape()
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.server")
await app.waitForFrame(
(frame) => frame.includes("fixture.server") && frame.includes(kind === "failed" ? "failed" : "↑ update"),
)
app.mockInput.pressEnter()
const shared = await app.waitForFrame((frame) => frame.includes("Installed"))
expect(shared).toMatch(/Installed\s+9\.0\.0/)
expect(app.requests).toHaveLength(1)
},
)
test("failed checks keep existing error investigation accessible", async () => {
await using tmp = await tmpdir()
await using app = await boot(tmp.path, {
check: async () => {
throw new Error("Registry unavailable")
},
})
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { fail: true } }]
})
await app.waitFor(() =>
probe
.plugins()
.list()
.some((item) => item.status === "failed"),
)
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("failed"))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("View error details"))
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
const errors = await app.waitForFrame(
(frame) =>
frame.includes("Registry unavailable") &&
frame.includes("Fixture setup failed") &&
frame.includes("i investigate"),
)
expect(errors).toContain("Load error:")
expect(errors).toContain("Check error:")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("View error details"))
})
test.each(["opencode.system", entrypoint])("builtins and local sources have no package check: %s", async (spec) => {
await using tmp = await tmpdir()
await using app = await boot(tmp.path, { plugins: spec.startsWith("opencode.") ? [] : [spec] })
await open(app, spec.startsWith("opencode.") ? "opencode" : "fixture.inspection")
app.mockInput.pressEnter()
const frame = await app.waitForFrame((frame) => frame.includes("Not applicable"))
expect(frame).not.toContain("Check for updates")
expect(frame).not.toMatch(/Update package|Reload installed/)
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) =>
frame.includes(spec.startsWith("opencode.") ? "Updates with OpenCode" : "no package check"),
)
expect(app.requests).toEqual([])
})
test.each([false, true])(
"real paging keys expose overflowing package revisions with Kitty %s",
async (kittyKeyboard) => {
await using tmp = await tmpdir()
const source = `git+https://example.com/${"nested-directory/".repeat(16)}plugin.git#main`
const one = "a".repeat(40)
const two = "b".repeat(40)
await Bun.write(path.join(tmp.path, "overflow.ts"), 'export default { id: "fixture.overflow", setup() {} }\n')
await using app = await boot(tmp.path, {
kittyKeyboard,
plugins: [source],
resolve: async () => ({ entrypoint: pathToFileURL(path.join(tmp.path, "overflow.ts")).href, revision: one }),
check: async () => ({ installed: one, available: two, mutable: true }),
})
await open(app, "fixture.overflow")
app.mockInput.pressEnter()
const top = await app.waitForFrame((frame) => frame.includes("Source") && frame.includes("Check for updates"))
expect(top).not.toContain("Available")
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("New revision available"))
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => !frame.includes("Runtime"))
app.mockInput.pressKey("\x1b[6~")
const scrolled = await app.waitForFrame((frame) => frame.includes("Installed") && frame.includes("Available"))
expect(scrolled).toContain(one)
expect(scrolled).toContain(two)
app.mockInput.pressKey("\x1b[5~")
app.mockInput.pressKey("\x1b[5~")
await app.waitForFrame((frame) => frame.includes("Runtime") && !frame.includes("Available"))
},
)
+1 -1
View File
@@ -103,7 +103,7 @@ test.each([
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
+2
View File
@@ -53,6 +53,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -62,6 +63,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:"
}
}
+98 -21
View File
@@ -22,6 +22,7 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly revision?: string
}
export interface Interface {
@@ -30,6 +31,9 @@ export interface Interface {
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly check: (
pkg: string,
) => Effect.Effect<{ installed?: string; available?: string; mutable: boolean }, InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -99,6 +103,11 @@ interface ArboristTree {
const PackageJson = Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
version: Schema.optional(Schema.String),
})
const PackageLock = Schema.Struct({
packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))),
})
const layer = Layer.effect(
@@ -113,6 +122,15 @@ const layer = Layer.effect(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
)
const parse = (pkg: string) =>
Effect.promise(async () => {
const { default: npa } = await import("npm-package-arg")
try {
return npa(pkg)
} catch {
return undefined
}
})
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
const manifest = yield* afs
@@ -124,6 +142,41 @@ const layer = Layer.effect(
}
return pkg
})
const installedRevision = Effect.fnUntraced(function* (root: string, name: string, dir: string, type?: string) {
if (type !== "git") {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
return Option.isSome(manifest) ? manifest.value.version : undefined
}
if (!(yield* afs.existsSafe(dir))) return undefined
// The wrapper lock records the installed Git revision, not the package's own dependency lock.
for (const file of [
path.join(root, "package-lock.json"),
path.join(root, "node_modules", ".package-lock.json"),
]) {
const lock = yield* afs
.readJson(file)
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
const revision = gitRevision(
Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`]?.resolved : undefined,
)
if (revision) return revision
}
return undefined
})
const entry = Effect.fnUntraced(function* (
root: string,
name: string,
dir: string,
type?: string,
subpaths?: readonly string[],
) {
return {
...resolveEntryPoint(name, dir, subpaths),
revision: yield* installedRevision(root, name, dir, type),
}
})
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
Effect.gen(function* () {
@@ -166,14 +219,7 @@ const layer = Layer.effect(
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsed = (() => {
try {
return npa(pkg)
} catch {
return undefined
}
})()
const parsed = yield* parse(pkg)
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
@@ -189,37 +235,59 @@ const layer = Layer.effect(
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
return yield* entry(dir, name, path.join(dir, "node_modules", name), parsed?.type, options?.subpaths)
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
const result = resolveEntryPoint(installed, path.join(dir, "node_modules", installed), options?.subpaths)
const result = yield* entry(
dir,
installed,
path.join(dir, "node_modules", installed),
parsed?.type,
options?.subpaths,
)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path, options?.subpaths)
return yield* entry(dir, first.name, first.path, parsed?.type, options?.subpaths)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
try {
return npa(pkg).name ?? undefined
} catch {
return undefined
}
})()
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
const name = yield* installedName(pkg, root, parsedName)
const name = yield* installedName(pkg, root, parsed?.name ?? undefined)
const dir = path.join(root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
return yield* entry(root, name, dir, parsed?.type, options?.subpaths)
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
if (
!parsed ||
(parsed.type !== "git" && !(parsed.name !== undefined && ["version", "range", "tag"].includes(parsed.type)))
)
return yield* new InstallFailedError({
dir: root,
cause: new Error("Package checks only support registry and Git package specs"),
})
const name = yield* installedName(pkg, root, parsed.name ?? undefined)
const installed = yield* installedRevision(root, name, path.join(root, "node_modules", name), parsed.type)
const { manifest, resolve } = yield* Effect.promise(() => import("pacote"))
const options = { ...(yield* NpmConfig.load(root)), preferOnline: true, noGitRevCache: true, ignoreScripts: true }
const available = yield* Effect.tryPromise({
try: async () =>
parsed.type === "git" ? gitRevision(await resolve(pkg, options)) : (await manifest(pkg, options)).version,
catch: (cause) => new InstallFailedError({ dir: root, cause }),
})
return { installed, available, mutable: isMutable(parsed) }
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
@@ -276,6 +344,7 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
which,
})
}),
@@ -301,6 +370,14 @@ export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
export async function check(...args: Parameters<Interface["check"]>) {
return runPromise((svc) => svc.check(...args))
}
function gitRevision(resolved: string | undefined) {
return resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1]
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
+153
View File
@@ -486,6 +486,123 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
+153
View File
@@ -486,6 +486,123 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -6,6 +6,29 @@ Plugins configured in `opencode.json(c)` that expose a TUI component are loaded
to build plugins, see [Building plugins](/build/plugins). You do not need to add the same package to `cli.json`. The CLI
gets the active plugin list from the connected OpenCode server, so this also works when the server is remote.
## Inspect plugins
Open **/plugins**, select a row, and press **Enter** to view its runtime, status, source, and scope. **Space** still enables
or disables a TUI plugin in the current terminal.
For package plugins, select **Check for updates**, or press **Ctrl+R** on the selected row. Checks are explicit; OpenCode
does not poll for plugin updates in the background. Details distinguish the **Loaded** revision captured when the code was
loaded, the **Installed** revision currently on disk, and the **Available** revision allowed by the configured package spec.
A small update marker appears after a check finds a different available revision. Unavailable revision metadata is shown
as unknown rather than inferred from the package spec.
Matching server and TUI package rows share a check on the connected server in the default Location used to discover
server plugins. Packages configured only in the CLI are checked on the TUI machine. Checking does not install packages or replace running code.
Exact npm versions and full Git commit hashes remain pinned; change their configured spec to select another revision.
Built-in plugins update with OpenCode, and local files do not have package update checks.
To apply a newer server package revision, use the existing [restart workflow](/plugins), then close and reopen the TUI to
load its companion. The Plugins dialog only inspects and checks revisions; it does not apply updates.
For a package configured only in `cli.json`, select a newer version or Git commit in its package spec, then reopen the TUI.
Reopening the TUI alone does not refresh a cached CLI-only package.
## CLI-only plugins
Use `cli.json` for CLI-only plugins. These plugins run locally in the terminal and remain active when the CLI connects
to a remote server: