mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f9e71df8 | |||
| 5ceeeb3e2c |
@@ -160,29 +160,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
Spec.make("plugin", {
|
||||
description: "Manage plugins",
|
||||
commands: [
|
||||
Spec.make("list", {
|
||||
description: "List plugins",
|
||||
params: {
|
||||
builtin: Flag.boolean("builtin").pipe(
|
||||
Flag.withDescription("Include built-in server plugins"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("models", {
|
||||
description: "List all available models",
|
||||
|
||||
@@ -84,12 +84,8 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
resolve: (spec) =>
|
||||
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
import { Config } from "../../../config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.add,
|
||||
Effect.fn("cli.plugin.add")(function* (input) {
|
||||
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
|
||||
return yield* Effect.fail(
|
||||
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
|
||||
if (!target)
|
||||
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
|
||||
|
||||
if (target === "server") {
|
||||
const global = yield* Global.Service
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
|
||||
const changed = yield* Effect.promise(() => writePluginConfig(configPath, input.package))
|
||||
process.stdout.write(
|
||||
changed
|
||||
? `Plugin "${input.package}" installed and added to ${configPath}${EOL}`
|
||||
: `Plugin "${input.package}" is already configured in ${configPath}${EOL}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const config = yield* Config.Service
|
||||
yield* config.update((draft) => {
|
||||
if (configured(draft.plugins, input.package)) return
|
||||
draft.plugins = [...(draft.plugins ?? []), input.package]
|
||||
})
|
||||
process.stdout.write(`TUI plugin "${input.package}" installed and added to ${config.path}${EOL}`)
|
||||
}),
|
||||
)
|
||||
|
||||
export function configurationTarget(server?: string, tui?: string) {
|
||||
if (server) return "server" as const
|
||||
if (tui) return "tui" as const
|
||||
}
|
||||
|
||||
export async function writePluginConfig(configPath: string, spec: string) {
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
|
||||
throw error
|
||||
})
|
||||
const errors: ParseError[] = []
|
||||
const config: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
|
||||
throw new Error(`Invalid global configuration: ${configPath}`)
|
||||
const plugins = "plugins" in config ? config.plugins : undefined
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
|
||||
if (configured(plugins, spec)) return false
|
||||
|
||||
const updated = applyEdits(
|
||||
text,
|
||||
modify(text, ["plugins"], [...(plugins ?? []), spec], { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
await mkdir(path.dirname(configPath), { recursive: true })
|
||||
const temporary = configPath + ".tmp"
|
||||
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
|
||||
await rename(temporary, configPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function configured(plugins: readonly unknown[] | undefined, spec: string) {
|
||||
return plugins?.some(
|
||||
(entry) =>
|
||||
entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec),
|
||||
)
|
||||
}
|
||||
@@ -5,69 +5,24 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
Effect.fn("cli.plugin.list")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[
|
||||
...(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
|
||||
}),
|
||||
...discovered.map((target) => ({ target, source: "discovered" as const })),
|
||||
],
|
||||
input.builtin,
|
||||
)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(output + EOL)
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
export function format(
|
||||
plugins: readonly PluginInfo[],
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
|
||||
builtin = false,
|
||||
) {
|
||||
const server = plugins
|
||||
.filter((plugin) => builtin || plugin.source.type !== "builtin")
|
||||
.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
.map((plugin) => `${name(plugin)} (${plugin.status})`)
|
||||
const advertised = plugins.flatMap((plugin) =>
|
||||
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: [],
|
||||
)
|
||||
const targets = [...tui, ...advertised]
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
.toSorted((a, b) => a.target.localeCompare(b.target))
|
||||
.map((plugin) => `${plugin.target} (${plugin.source})`)
|
||||
return [
|
||||
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
|
||||
server.length ? ["Server", ...server].join(EOL) : undefined,
|
||||
]
|
||||
.filter((section) => section !== undefined)
|
||||
.join(EOL + EOL)
|
||||
}
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Config } from "../../../config"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.remove,
|
||||
Effect.fn("cli.plugin.remove")(function* (input) {
|
||||
const global = yield* Global.Service
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
|
||||
const server = yield* Effect.promise(() => removePluginConfig(configPath, input.package))
|
||||
const config = yield* Config.Service
|
||||
const info = yield* config.get()
|
||||
const tui = configured(info.plugins, input.package)
|
||||
if (tui)
|
||||
yield* config.update((draft) => {
|
||||
draft.plugins = draft.plugins?.filter((entry) => !matches(entry, input.package))
|
||||
})
|
||||
|
||||
const removed = [server ? configPath : undefined, tui ? config.path : undefined].filter(
|
||||
(file) => file !== undefined,
|
||||
)
|
||||
process.stdout.write(
|
||||
removed.length
|
||||
? `Plugin "${input.package}" removed from ${removed.join(", ")}${EOL}`
|
||||
: `Plugin "${input.package}" is not configured${EOL}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export async function removePluginConfig(configPath: string, spec: string) {
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined
|
||||
throw error
|
||||
})
|
||||
if (text === undefined) return false
|
||||
const errors: ParseError[] = []
|
||||
const config: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
|
||||
throw new Error(`Invalid global configuration: ${configPath}`)
|
||||
const plugins = "plugins" in config ? config.plugins : undefined
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
|
||||
if (!configured(plugins, spec)) return false
|
||||
|
||||
const updated = applyEdits(
|
||||
text,
|
||||
modify(
|
||||
text,
|
||||
["plugins"],
|
||||
plugins?.filter((entry) => !matches(entry, spec)),
|
||||
{
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
},
|
||||
),
|
||||
)
|
||||
const temporary = configPath + ".tmp"
|
||||
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
|
||||
await rename(temporary, configPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function configured(plugins: readonly unknown[] | undefined, spec: string) {
|
||||
return plugins?.some((entry) => matches(entry, spec)) ?? false
|
||||
}
|
||||
|
||||
function matches(entry: unknown, spec: string) {
|
||||
return entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec)
|
||||
}
|
||||
@@ -38,8 +38,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
add: () => import("./commands/handlers/plugin/add"),
|
||||
remove: () => import("./commands/handlers/plugin/remove"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
|
||||
|
||||
test("routes packages according to their exported runtimes", () => {
|
||||
expect(configurationTarget("server.js", "tui.js")).toBe("server")
|
||||
expect(configurationTarget("server.js", undefined)).toBe("server")
|
||||
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
|
||||
expect(configurationTarget(undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("adds a package to global plugin config without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
|
||||
|
||||
try {
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({
|
||||
model: "provider/model",
|
||||
plugins: ["first", "second@1.0.0"],
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { format } from "../src/commands/handlers/plugin/list"
|
||||
|
||||
test("formats server and TUI plugins in sections without builtins", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", package: "acme-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
},
|
||||
{
|
||||
source: { type: "package", package: "broken-plugin" },
|
||||
status: "failed",
|
||||
error: "broken",
|
||||
tui: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ target: "tui-only", source: "configured" },
|
||||
{ target: "/tmp/local.ts", source: "discovered" },
|
||||
],
|
||||
),
|
||||
).toBe(`TUI
|
||||
/tmp/local.ts (discovered)
|
||||
acme-plugin@1.0.0 (advertised)
|
||||
tui-only (configured)
|
||||
|
||||
Server
|
||||
acme.dual (active)
|
||||
broken-plugin (failed)`)
|
||||
})
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
expect(format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true))
|
||||
.toBe(`Server
|
||||
opencode.agent (active)`)
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
|
||||
|
||||
test("removes string and object package entries without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await Bun.write(
|
||||
file,
|
||||
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
|
||||
)
|
||||
|
||||
try {
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(true)
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -26,6 +26,7 @@ function resolveLocation(options: Record<string, any>) {
|
||||
|
||||
function vertexEndpoint(location: string) {
|
||||
if (location === "global") return "aiplatform.googleapis.com"
|
||||
if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
|
||||
return `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
@@ -114,13 +115,18 @@ export const GoogleVertexPlugin = define({
|
||||
if (evt.package !== "@ai-sdk/google-vertex") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex"))
|
||||
const project = resolveProject(evt.options)
|
||||
const location = resolveLocation(evt.options)
|
||||
const location = String(resolveLocation(evt.options))
|
||||
const options = { ...evt.options }
|
||||
delete options.fetch
|
||||
evt.sdk = mod.createVertex({
|
||||
...options,
|
||||
project,
|
||||
location,
|
||||
...((location === "eu" || location === "us") && project && !options.apiKey && !options.baseURL
|
||||
? {
|
||||
baseURL: `https://${vertexEndpoint(location)}/v1beta1/projects/${project}/locations/${location}/publishers/google`,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,17 +35,6 @@ describe("Npm.sanitize", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.isRegistryPackage", () => {
|
||||
test("accepts registry packages and rejects unsupported install targets", async () => {
|
||||
expect(await Npm.isRegistryPackage("plugin")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
|
||||
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
|
||||
expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -117,31 +106,3 @@ describe("Npm.add", () => {
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
test("resolves a TUI entrypoint only when the package is already cached", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "fixture-plugin@1.0.0"
|
||||
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
|
||||
const missing = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(missing.entrypoint).toBeUndefined()
|
||||
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, {
|
||||
name: "fixture-plugin",
|
||||
exports: { ".": "./index.js", "./tui": "./tui.js" },
|
||||
})
|
||||
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
|
||||
await Bun.write(path.join(directory, "tui.js"), "export default {}\n")
|
||||
|
||||
const resolved = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
||||
it.effect("uses the REP endpoint for OpenAI-compatible Vertex multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
@@ -294,11 +294,65 @@ describe("GoogleVertexPlugin", () => {
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" },
|
||||
settings: {
|
||||
baseURL: "https://aiplatform.eu.rep.googleapis.com/v1/projects/config-project/locations/eu",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the REP endpoint for the native Vertex SDK in multi-regions", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_CLOUD_PROJECT: "env-project",
|
||||
GOOGLE_VERTEX_LOCATION: "us",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
|
||||
modelID: Model.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
})
|
||||
expect(vertexOptions).toHaveLength(1)
|
||||
expect(vertexOptions[0].baseURL).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1beta1/projects/env-project/locations/us/publishers/google",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves custom native Vertex base URLs in multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
|
||||
modelID: Model.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: {
|
||||
name: "google-vertex",
|
||||
project: "project",
|
||||
location: "eu",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
},
|
||||
})
|
||||
expect(vertexOptions).toHaveLength(1)
|
||||
expect(vertexOptions[0].baseURL).toBe("https://vertex.example/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults location to us-central1 when only project is configured", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"./context/client": "./src/context/client.tsx",
|
||||
"./context/theme": "./src/context/theme.tsx",
|
||||
"./theme/discovery": "./src/theme/discovery.ts",
|
||||
"./plugin/discovery": "./src/plugin/discovery.ts",
|
||||
"./context/editor": "./src/context/editor.ts",
|
||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||
"./attention": "./src/attention.ts",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
@@ -23,8 +21,6 @@ import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
import { useConfig } from "../config"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
@@ -32,7 +28,7 @@ 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) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -94,13 +90,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [serverPlugins, setServerPlugins] = createSignal<
|
||||
ReadonlyArray<
|
||||
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
|
||||
>
|
||||
>([])
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -241,11 +230,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const npmFailures = new Map<string, string>()
|
||||
const reconcile = async () => {
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [
|
||||
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
|
||||
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
|
||||
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
|
||||
]
|
||||
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
@@ -253,8 +238,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
for (const plugin of builtins)
|
||||
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
const failures: State[] = []
|
||||
for (const source of entries) {
|
||||
const entry = source.entry
|
||||
for (const entry of entries) {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-")) {
|
||||
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
|
||||
@@ -275,12 +259,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
|
||||
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}))
|
||||
if (resolved.status === "unsupported") {
|
||||
if (source.server) continue
|
||||
failures.push({ target, status: "unsupported" })
|
||||
continue
|
||||
}
|
||||
@@ -456,7 +439,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
() => {
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
@@ -466,29 +449,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
},
|
||||
),
|
||||
)
|
||||
const syncServerPlugins = () =>
|
||||
client.api.plugin
|
||||
.list({ location: data.location.default() })
|
||||
.then((response) =>
|
||||
setServerPlugins(
|
||||
response.data.filter(
|
||||
(
|
||||
plugin,
|
||||
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
|
||||
readonly source: { readonly type: "package" }
|
||||
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(data.location.default()),
|
||||
() => void syncServerPlugins(),
|
||||
),
|
||||
)
|
||||
onCleanup(client.event.on("plugin.updated", syncServerPlugins))
|
||||
onCleanup(client.event.on("server.connected", syncServerPlugins))
|
||||
onMount(() => {
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
@@ -563,13 +523,12 @@ async function resolvePlugin(
|
||||
options: Readonly<Record<string, any>> | undefined,
|
||||
previous: Registration | undefined,
|
||||
packages: PackageResolver,
|
||||
install: boolean,
|
||||
) {
|
||||
// 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)
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// The cache-busted specifier doubles as the version: unique per entrypoint
|
||||
// and mtime, so equal versions mean an identical module.
|
||||
|
||||
@@ -95,11 +95,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
|
||||
if (url.pathname === "/api/plugin")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Effect, FileSystem } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEventStream, createFetch, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -31,26 +30,12 @@ async function until(read: () => Promise<string>, expected: (value: string | und
|
||||
return value
|
||||
}
|
||||
|
||||
async function bootApp(
|
||||
directory: string,
|
||||
options?: {
|
||||
plugins?: unknown[]
|
||||
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
|
||||
},
|
||||
) {
|
||||
async function bootApp(directory: string) {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/plugin")
|
||||
return json({
|
||||
location: {
|
||||
directory,
|
||||
project: { id: "proj_test", directory, canonical: directory },
|
||||
},
|
||||
data: options?.plugins ?? [],
|
||||
})
|
||||
if (url.pathname !== "/api/fs/list") return
|
||||
return json({
|
||||
location: {
|
||||
@@ -69,7 +54,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: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
@@ -88,40 +73,6 @@ async function bootApp(
|
||||
}
|
||||
}
|
||||
|
||||
test("loads an advertised package TUI entrypoint only from the local cache", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const marker = path.join(tmp.path, "marker.txt")
|
||||
const entrypoint = path.join(tmp.path, "tui.ts")
|
||||
await writeFile(entrypoint, lifecycleSource(marker, "test.package", "package"))
|
||||
const resolutions: Array<{ spec: string; install?: boolean }> = []
|
||||
|
||||
await using app = await bootApp(tmp.path, {
|
||||
plugins: [
|
||||
{
|
||||
id: "test.server",
|
||||
source: { type: "package", package: "test-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
},
|
||||
],
|
||||
resolve: async (spec, install) => {
|
||||
resolutions.push({ spec, install })
|
||||
return pathToFileURL(entrypoint).href
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await until(
|
||||
() => readFile(marker, "utf8"),
|
||||
(value) => value === "package:setup\n",
|
||||
),
|
||||
).toBe("package:setup\n")
|
||||
expect(resolutions).toContainEqual({ spec: "test-plugin@1.0.0", install: false })
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("discovers an ancestor TUI plugin directory created after startup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface Interface {
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -42,16 +41,6 @@ export function sanitize(pkg: string) {
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
export async function isRegistryPackage(pkg: string) {
|
||||
const { default: npa } = await import("npm-package-arg")
|
||||
try {
|
||||
const result = npa(pkg)
|
||||
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
@@ -145,23 +134,6 @@ const layer = Layer.effect(
|
||||
return resolveEntryPoint(first.name, first.path, 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 name = (() => {
|
||||
try {
|
||||
return npa(pkg).name ?? pkg
|
||||
} catch {
|
||||
return pkg
|
||||
}
|
||||
})()
|
||||
const dir = path.join(directory(pkg), "node_modules", name)
|
||||
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
|
||||
return resolveEntryPoint(name, dir, options?.subpaths)
|
||||
})
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
@@ -215,7 +187,6 @@ const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
resolve,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
@@ -233,10 +204,6 @@ export async function add(...args: Parameters<Interface["add"]>) {
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function resolve(...args: Parameters<Interface["resolve"]>) {
|
||||
return runPromise((svc) => svc.resolve(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
+1
-35
@@ -99,32 +99,6 @@ an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Install a package plugin globally with the CLI:
|
||||
|
||||
```sh
|
||||
opencode2 plugin add opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
This installs and inspects the package before changing configuration. Packages
|
||||
with a server entrypoint are added to global `opencode.json(c)`. Packages that
|
||||
only expose `./tui` are added to global `cli.json` instead.
|
||||
|
||||
The command accepts npm registry package names with an optional version,
|
||||
dist-tag, or semver range. Configure local paths directly instead; Git, tarball,
|
||||
and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
List configured and active plugins, or remove a package from both global server
|
||||
and TUI configuration:
|
||||
|
||||
```sh
|
||||
opencode2 plugin list
|
||||
opencode2 plugin list --builtin
|
||||
opencode2 plugin remove opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
Built-in server plugins are hidden from the default list. Removing a plugin
|
||||
keeps its package cache available for later reuse.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
@@ -423,21 +397,13 @@ manifest is:
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "beta"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Packages with a TUI entrypoint should set `tui: true` on their server plugin
|
||||
definition. A locally connected TUI loads the package's `./tui` export from the
|
||||
existing OpenCode package cache. A TUI connected to a remote server skips it
|
||||
when that package is not installed locally.
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
|
||||
Reference in New Issue
Block a user