diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index ba11cd7630..12852d7709 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -160,7 +160,29 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("plugin", { description: "Manage plugins", - commands: [Spec.make("list", { description: "List active 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")), + }, + }), + ], }), Spec.make("models", { description: "List all available models", diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 72d389152b..35c5635a09 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -84,8 +84,12 @@ export default Runtime.handler(Commands, (input) => update: (update) => runPromise(config.update(update)), }, packages: { - resolve: (spec) => - runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), + resolve: (spec, install = true) => + runPromise( + (install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe( + Effect.map((result) => result.entrypoint), + ), + ), }, environment: requestedServer === undefined ? Env.session() : undefined, terminalHandoff: () => preflight.finish(), diff --git a/packages/cli/src/commands/handlers/plugin/add.ts b/packages/cli/src/commands/handlers/plugin/add.ts new file mode 100644 index 0000000000..c33049c216 --- /dev/null +++ b/packages/cli/src/commands/handlers/plugin/add.ts @@ -0,0 +1,82 @@ +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), + ) +} diff --git a/packages/cli/src/commands/handlers/plugin/list.ts b/packages/cli/src/commands/handlers/plugin/list.ts index ca276f4ea7..35b6c3b4ad 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -5,24 +5,69 @@ 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* () { + Effect.fn("cli.plugin.list")(function* (input) { 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 plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b))) - if (plugins.length === 0) { - process.stdout.write("No plugins loaded" + EOL) + 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) return } - process.stdout.write(plugins.map(name).join(EOL) + EOL) + process.stdout.write(output + 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 diff --git a/packages/cli/src/commands/handlers/plugin/remove.ts b/packages/cli/src/commands/handlers/plugin/remove.ts new file mode 100644 index 0000000000..b93000bd67 --- /dev/null +++ b/packages/cli/src/commands/handlers/plugin/remove.ts @@ -0,0 +1,74 @@ +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) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6521ecb064..2f2be4dd69 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -38,6 +38,8 @@ 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"), diff --git a/packages/cli/test/plugin-add.test.ts b/packages/cli/test/plugin-add.test.ts new file mode 100644 index 0000000000..b8480697df --- /dev/null +++ b/packages/cli/test/plugin-add.test.ts @@ -0,0 +1,30 @@ +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}` + } +}) diff --git a/packages/cli/test/plugin-list.test.ts b/packages/cli/test/plugin-list.test.ts new file mode 100644 index 0000000000..285a452094 --- /dev/null +++ b/packages/cli/test/plugin-list.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { EOL } from "node:os" +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)", + ].join(EOL), + ) +}) + +test("includes builtins when requested", () => { + expect( + format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true), + ).toBe(["Server", "opencode.agent (active)"].join(EOL)) +}) diff --git a/packages/cli/test/plugin-remove.test.ts b/packages/cli/test/plugin-remove.test.ts new file mode 100644 index 0000000000..2c0e5b9a88 --- /dev/null +++ b/packages/cli/test/plugin-remove.test.ts @@ -0,0 +1,23 @@ +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}` + } +}) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index b970aad111..bf41b99be7 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -35,6 +35,17 @@ 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() @@ -106,3 +117,31 @@ 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") + }) +}) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 0f1ba64004..eead38b3ad 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -31,6 +31,7 @@ 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), }), ) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index d8949fd7d0..b70da8f6a0 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -23,6 +23,7 @@ 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), }) } diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 3ce68ad826..456af2cbcb 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -14,6 +14,7 @@ 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), }) diff --git a/packages/tui/package.json b/packages/tui/package.json index cc97e9ca64..4ac1e38179 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -24,6 +24,7 @@ "./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", diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index d64adadc6b..ca3415372f 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -1,3 +1,4 @@ +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 { @@ -5,6 +6,7 @@ import { createContext, createEffect, createMemo, + createSignal, on, onCleanup, onMount, @@ -21,6 +23,8 @@ 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" @@ -28,7 +32,7 @@ import { createSourceWatcher } from "./watch" import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery" export interface PackageResolver { - readonly resolve: (spec: string) => Promise + readonly resolve: (spec: string, install?: boolean) => Promise } type State = @@ -90,6 +94,13 @@ 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 & { readonly source: { readonly type: "package" } } + > + >([]) const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -230,7 +241,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d const npmFailures = new Map() const reconcile = async () => { await Promise.all(props.directories.map(watcher.wait)) - const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])] + 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 })), + ] // Resolve: fold entries into one desired generation. A source that fails // to import keeps its running previous version and only reports failure. @@ -238,7 +253,8 @@ 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 entry of entries) { + for (const source of entries) { + const entry = source.entry 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 @@ -259,11 +275,12 @@ 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).catch((error) => ({ + : await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({ status: "failed" as const, error: errorMessage(error), })) if (resolved.status === "unsupported") { + if (source.server) continue failures.push({ target, status: "unsupported" }) continue } @@ -439,7 +456,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(config.data.plugins ?? []), + () => JSON.stringify([serverPlugins(), config.data.plugins ?? []]), () => { npmFailures.clear() void enqueue(reconcile).then( @@ -449,6 +466,29 @@ 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 & { + 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 | undefined const dispose = () => { @@ -523,12 +563,13 @@ async function resolvePlugin( options: Readonly> | 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) + const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install) 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. diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 51f79a6a84..5fcb6f4bbd 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Promise, expected: (value: string | und return value } -async function bootApp(directory: string) { +async function bootApp( + directory: string, + options?: { + plugins?: unknown[] + resolve?: (spec: string, install?: boolean) => Promise + }, +) { 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: { @@ -54,7 +69,7 @@ async function bootApp(directory: string) { app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, - packages: { resolve: async () => undefined }, + packages: { resolve: options?.resolve ?? (async () => undefined) }, args: {}, log: () => {}, }).pipe( @@ -73,6 +88,40 @@ async function bootApp(directory: string) { } } +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") diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 7915165e00..934a04f719 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -29,6 +29,7 @@ export interface Interface { pkg: string, options?: { readonly subpaths?: readonly string[] }, ) => Effect.Effect + readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect readonly which: (pkg: string, bin?: string) => Effect.Effect } @@ -41,6 +42,16 @@ 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) => { @@ -134,6 +145,23 @@ 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") @@ -187,6 +215,7 @@ const layer = Layer.effect( return Service.of({ add, + resolve, which, }) }), @@ -204,6 +233,10 @@ export async function add(...args: Parameters) { return runPromise((svc) => svc.add(...args)) } +export async function resolve(...args: Parameters) { + return runPromise((svc) => svc.resolve(...args)) +} + export async function which(...args: Parameters) { return runPromise((svc) => svc.which(...args)) } diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index d60386d38d..ebcaee1f2a 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -99,6 +99,32 @@ 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: @@ -397,13 +423,21 @@ manifest is: "name": "opencode-acme-plugin", "version": "1.0.0", "type": "module", - "exports": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./tui": "./src/tui.tsx" + }, "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