diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e9..978c26544a5 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -6,8 +6,12 @@ import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { MCP } from "../mcp" +import { Log } from "../util/log" +import { PluginCommand } from "../plugin/command" export namespace Command { + const log = Log.create({ service: "command" }) + export const Event = { Executed: BusEvent.define( "command.executed", @@ -39,6 +43,7 @@ export namespace Command { // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } + export type Entry = Info & { mode?: PluginCommand.Mode } export function hints(template: string): string[] { const result: string[] = [] @@ -58,7 +63,7 @@ export namespace Command { const state = Instance.state(async () => { const cfg = await Config.get() - const result: Record = { + const result: Record = { [Default.INIT]: { name: Default.INIT, description: "create/update AGENTS.md", @@ -118,6 +123,24 @@ export namespace Command { } } + const plugins = await PluginCommand.list() + for (const item of Object.values(plugins)) { + if (result[item.name]) { + log.warn("plugin command ignored due to collision", { command: item.name, plugin: item.source }) + continue + } + result[item.name] = { + name: item.name, + description: item.description, + agent: item.agent, + model: item.model, + subtask: item.subtask, + hints: item.hints, + template: item.template, + mode: item.mode, + } + } + return result }) diff --git a/packages/opencode/src/plugin/command-service.ts b/packages/opencode/src/plugin/command-service.ts new file mode 100644 index 00000000000..ac5a3ea3b7c --- /dev/null +++ b/packages/opencode/src/plugin/command-service.ts @@ -0,0 +1,137 @@ +import type { PluginCommandInput } from "@opencode-ai/plugin" +import { Command } from "../command" +import { Bus } from "../bus" +import { Identifier } from "../id/id" +import { Instance } from "../project/instance" +import { Session } from "../session" +import { MessageV2 } from "../session/message-v2" +import { PluginCommand } from "./command" +import type { SessionPrompt } from "../session/prompt" +import { NamedError } from "@opencode-ai/util/error" + +export namespace PluginCommandService { + export async function execute(input: { + command: Command.Entry + request: SessionPrompt.CommandInput + agent: string + model: { providerID: string; modelID: string } + userMessage: MessageV2.WithParts + }): Promise { + const execution = await PluginCommand.execute(input.command.name, { + sessionID: input.request.sessionID, + command: input.request.command, + arguments: input.request.arguments, + messageID: input.userMessage.info.id, + agent: input.agent, + model: input.request.model ?? input.command.model ?? `${input.model.providerID}/${input.model.modelID}`, + variant: input.request.variant, + parts: input.userMessage.parts as PluginCommandInput["parts"], + }) + .then((result) => ({ result })) + .catch((error) => ({ error })) + + const errorResult = async (error: MessageV2.Assistant["error"], message: string) => { + const now = Date.now() + const info: MessageV2.Assistant = { + id: Identifier.ascending("message"), + sessionID: input.request.sessionID, + parentID: input.userMessage.info.id, + role: "assistant", + mode: input.agent, + agent: input.agent, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model.modelID, + providerID: input.model.providerID, + time: { + created: now, + completed: now, + }, + error, + } + const part: MessageV2.TextPart = { + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: info.sessionID, + type: "text", + text: message, + } + await Session.updateMessage(info) + await Session.updatePart(part) + Bus.publish(Session.Event.Error, { + sessionID: input.request.sessionID, + error, + }) + return { info, parts: [part] } + } + + if ("error" in execution) { + const error = MessageV2.fromError(execution.error, { providerID: input.model.providerID }) + const message = execution.error instanceof Error ? execution.error.message : "Plugin command failed" + return await errorResult(error, message) + } + + if (!execution.result) { + const error = new NamedError.Unknown({ message: "Plugin command not handled." }).toObject() + return await errorResult(error, "Plugin command not handled.") + } + + const now = Date.now() + const info: MessageV2.Assistant = { + id: Identifier.ascending("message"), + sessionID: input.request.sessionID, + parentID: input.userMessage.info.id, + role: "assistant", + mode: input.agent, + agent: input.agent, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model.modelID, + providerID: input.model.providerID, + time: { + created: now, + completed: now, + }, + } + const outputParts = execution.result.parts.map((part) => ({ + ...part, + id: part.id ?? Identifier.ascending("part"), + messageID: info.id, + sessionID: info.sessionID, + })) + await Session.updateMessage(info) + for (const part of outputParts) { + await Session.updatePart(part) + } + + Bus.publish(Command.Event.Executed, { + name: input.request.command, + sessionID: input.request.sessionID, + arguments: input.request.arguments, + messageID: info.id, + }) + + return { + info, + parts: outputParts, + } + } +} diff --git a/packages/opencode/src/plugin/command.ts b/packages/opencode/src/plugin/command.ts new file mode 100644 index 00000000000..7a3dbee301d --- /dev/null +++ b/packages/opencode/src/plugin/command.ts @@ -0,0 +1,94 @@ +import type { + Hooks, + PluginCommand as PluginCommandDefinition, + PluginCommandInput, + PluginCommandMode, + PluginCommandOutput, +} from "@opencode-ai/plugin" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { Plugin } from "." + +export namespace PluginCommand { + export type Mode = PluginCommandMode + export type Entry = { + name: string + description?: string + agent?: string + model?: string + subtask?: boolean + hints: string[] + template: Promise | string + mode: Mode + source: string + execute?: Hooks["command.execute"] + } + + const log = Log.create({ service: "plugin.command" }) + + const state = Instance.state(async () => { + const hooks = await Plugin.list() + const result: Record = {} + for (const hook of hooks) { + const commands = hook.command ?? [] + if (commands.length === 0) continue + const source = Plugin.name(hook) + const execute = hook["command.execute"] + for (const command of commands as PluginCommandDefinition[]) { + const name = command.name?.trim() + if (!name) { + log.warn("plugin command missing name", { plugin: source }) + continue + } + const mode = command.mode ?? "llm" + if (mode !== "llm" && mode !== "plugin") { + log.warn("plugin command invalid mode", { plugin: source, command: name, mode: command.mode }) + continue + } + if (mode === "llm" && !command.template) { + log.warn("plugin command missing template", { plugin: source, command: name }) + continue + } + if (mode === "plugin" && !execute) { + log.warn("plugin command missing handler", { plugin: source, command: name }) + continue + } + if (result[name]) { + log.warn("plugin command collision", { plugin: source, command: name, existing: result[name].source }) + continue + } + const hints = Array.isArray(command.hints) ? command.hints : [] + const template = command.template ?? "" + result[name] = { + name, + description: command.description, + agent: command.agent, + model: command.model, + subtask: command.subtask, + hints, + template, + mode, + source, + execute, + } + } + } + return result + }) + + export async function list() { + return state() + } + + export async function get(name: string) { + return state().then((x) => x[name]) + } + + export async function execute(name: string, input: PluginCommandInput): Promise { + const entry = await get(name) + if (!entry) return undefined + if (entry.mode !== "plugin") return undefined + if (!entry.execute) return undefined + return entry.execute(input) + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6032935f848..fd75cd0a5d6 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,12 +14,18 @@ import { CopilotAuthPlugin } from "./copilot" export namespace Plugin { const log = Log.create({ service: "plugin" }) + const names = new WeakMap() const BUILTIN = ["opencode-anthropic-auth@0.0.13", "@gitlab/opencode-gitlab-auth@1.3.2"] // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin] + const register = (hook: Hooks, name: string, hooks: Hooks[]) => { + names.set(hook, name) + hooks.push(hook) + } + const state = Instance.state(async () => { const client = createOpencodeClient({ baseUrl: "http://localhost:4096", @@ -40,7 +46,8 @@ export namespace Plugin { for (const plugin of INTERNAL_PLUGINS) { log.info("loading internal plugin", { name: plugin.name }) const init = await plugin(input) - hooks.push(init) + const name = plugin.name || "internal" + register(init, name, hooks) } const plugins = [...(config.plugin ?? [])] @@ -77,6 +84,7 @@ export namespace Plugin { if (!plugin) continue } const mod = await import(plugin) + const pluginName = Config.getPluginName(plugin) // Prevent duplicate initialization when plugins export the same function // as both a named export and default export (e.g., `export const X` and `export default X`). // Object.entries(mod) would return both entries pointing to the same function reference. @@ -85,7 +93,7 @@ export namespace Plugin { if (seen.has(fn)) continue seen.add(fn) const init = await fn(input) - hooks.push(init) + register(init, pluginName, hooks) } } @@ -96,7 +104,7 @@ export namespace Plugin { }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "command">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -116,6 +124,10 @@ export namespace Plugin { return state().then((x) => x.hooks) } + export function name(hook: Hooks) { + return names.get(hook) ?? "plugin" + } + export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 94eabdef7f4..f2d61c1ed75 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -17,6 +17,7 @@ import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { InstructionPrompt } from "./instruction" import { Plugin } from "../plugin" +import { PluginCommandService } from "../plugin/command-service" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" @@ -1604,8 +1605,99 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!command) { + const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } + const mode = command.mode ?? "llm" const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) + const taskModel = await (async () => { + if (command.model) { + return Provider.parseModel(command.model) + } + if (command.agent) { + const cmdAgent = await Agent.get(command.agent) + if (cmdAgent?.model) { + return cmdAgent.model + } + } + if (input.model) return Provider.parseModel(input.model) + return await lastModel(input.sessionID) + })() + + try { + await Provider.getModel(taskModel.providerID, taskModel.modelID) + } catch (e) { + if (Provider.ModelNotFoundError.isInstance(e)) { + const { providerID, modelID, suggestions } = e.data + const hint = suggestions?.length ? ` Did you mean: ${suggestions.join(", ")}?` : "" + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(), + }) + } + throw e + } + const agent = await Agent.get(agentName) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } + + if (mode === "plugin") { + const summary = ["/" + input.command, input.arguments.trim()].filter(Boolean).join(" ") + const commandParts = summary + ? [ + { + type: "text" as const, + text: summary, + }, + ] + : [] + const parts: PromptInput["parts"] = [...commandParts, ...(input.parts ?? [])] + const userAgent = agentName + const userModel = taskModel + + await Plugin.trigger( + "command.execute.before", + { + command: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + }, + { parts }, + ) + + const userMessage = (await prompt({ + sessionID: input.sessionID, + messageID: input.messageID, + model: userModel, + agent: userAgent, + parts, + variant: input.variant, + noReply: true, + })) as MessageV2.WithParts + + return await PluginCommandService.execute({ + command, + request: input, + agent: agent.name, + model: userModel, + userMessage, + }) + } + const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) @@ -1651,45 +1743,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } template = template.trim() - const taskModel = await (async () => { - if (command.model) { - return Provider.parseModel(command.model) - } - if (command.agent) { - const cmdAgent = await Agent.get(command.agent) - if (cmdAgent?.model) { - return cmdAgent.model - } - } - if (input.model) return Provider.parseModel(input.model) - return await lastModel(input.sessionID) - })() - - try { - await Provider.getModel(taskModel.providerID, taskModel.modelID) - } catch (e) { - if (Provider.ModelNotFoundError.isInstance(e)) { - const { providerID, modelID, suggestions } = e.data - const hint = suggestions?.length ? ` Did you mean: ${suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(), - }) - } - throw e - } - const agent = await Agent.get(agentName) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } - const templateParts = await resolvePromptParts(template) const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true const parts = isSubtask diff --git a/packages/opencode/test/command/plugin-commands.test.ts b/packages/opencode/test/command/plugin-commands.test.ts new file mode 100644 index 00000000000..d635b0780fe --- /dev/null +++ b/packages/opencode/test/command/plugin-commands.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Command } from "../../src/command" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +const pluginPath = path.resolve(__dirname, "../fixtures/plugins/toggle.ts") +const pluginUrl = pathToFileURL(pluginPath).href + +Log.init({ print: false }) + +describe("plugin commands", () => { + test("lists plugin commands", async () => { + await using tmp = await tmpdir({ + config: { + plugin: [pluginUrl], + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const commands = await Command.list() + const names = commands.map((item) => item.name) + expect(names).toContain("toggle") + }, + }) + }) + + test("executes plugin-only commands", async () => { + await using tmp = await tmpdir({ + config: { + plugin: [pluginUrl], + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const agent = await Agent.defaultAgent() + const executed: { + value: + | { + name: string + sessionID: string + arguments: string + messageID: string + } + | undefined + } = { value: undefined } + const unsub = Bus.subscribe(Command.Event.Executed, (event) => { + executed.value = event.properties as typeof executed.value + }) + const result = await SessionPrompt.command({ + sessionID: session.id, + command: "toggle", + arguments: "on", + agent, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + unsub() + const text = result.parts.find((part) => part.type === "text") + expect(text?.type === "text" ? text.text : undefined).toBe("toggle:on") + expect(executed.value?.name).toBe("toggle") + expect(executed.value?.messageID).toBe(result.info.id) + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/fixtures/plugins/toggle.ts b/packages/opencode/test/fixtures/plugins/toggle.ts new file mode 100644 index 00000000000..a7fd27658b9 --- /dev/null +++ b/packages/opencode/test/fixtures/plugins/toggle.ts @@ -0,0 +1,32 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +const states = new Map() + +export default async function TogglePlugin(_input: PluginInput): Promise { + return { + command: [ + { + name: "toggle", + description: "toggle state", + hints: ["$ARGUMENTS"], + mode: "plugin", + }, + ], + "command.execute": async (input) => { + if (input.command !== "toggle") return undefined + const value = input.arguments.trim() || "off" + states.set(input.sessionID, value) + return { + parts: [ + { + id: "part", + sessionID: input.sessionID, + messageID: input.messageID ?? "message", + type: "text", + text: `toggle:${value}`, + }, + ], + } + }, + } +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 86e7ae93420..e4b2c22e783 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -34,6 +34,34 @@ export type PluginInput = { export type Plugin = (input: PluginInput) => Promise +export type PluginCommandMode = "llm" | "plugin" + +export type PluginCommand = { + name: string + description?: string + agent?: string + model?: string + subtask?: boolean + hints: string[] + template?: string | Promise + mode?: PluginCommandMode +} + +export type PluginCommandInput = { + sessionID: string + command: string + arguments: string + messageID?: string + agent: string + model?: string + variant?: string + parts?: Part[] +} + +export type PluginCommandOutput = { + parts: Part[] +} + export type AuthHook = { provider: string loader?: (auth: () => Promise, provider: Provider) => Promise> @@ -151,6 +179,7 @@ export interface Hooks { tool?: { [key: string]: ToolDefinition } + command?: PluginCommand[] auth?: AuthHook /** * Called when a new message is received @@ -181,6 +210,7 @@ export interface Hooks { input: { command: string; sessionID: string; arguments: string }, output: { parts: Part[] }, ) => Promise + "command.execute"?: (input: PluginCommandInput) => Promise "tool.execute.before"?: ( input: { tool: string; sessionID: string; callID: string }, output: { args: any }, diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index ba530a6d9ba..08592c5131f 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -289,6 +289,47 @@ Your custom tools will be available to opencode alongside built-in tools. --- +### Plugin commands + +Plugin commands let plugins trigger functionality directly from the prompt (`/command`), which is useful for managing plugin state or running server-side actions without prompting the LLM. + +```ts title=".opencode/plugins/toggle.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const TogglePlugin: Plugin = async () => { + return { + command: [ + { + name: "toggle", + description: "toggle plugin state", + hints: ["$ARGUMENTS"], + mode: "plugin", + }, + ], + "command.execute": async (input) => { + if (input.command !== "toggle") return undefined + const value = input.arguments.trim() || "off" + return { + parts: [ + { + type: "text", + text: `toggle:${value}`, + }, + ], + } + }, + } +} +``` + +Benefits: + +- Visible commands in the slash menu +- A clean entry point for plugin-managed state +- Deterministic outputs without LLM variability + +--- + ### Logging Use `client.app.log()` instead of `console.log` for structured logging: