Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export type Entry = Info & { mode?: PluginCommand.Mode }

export function hints(template: string): string[] {
const result: string[] = []
Expand All @@ -58,7 +63,7 @@ export namespace Command {
const state = Instance.state(async () => {
const cfg = await Config.get()

const result: Record<string, Info> = {
const result: Record<string, Entry> = {
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
Expand Down Expand Up @@ -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
})

Expand Down
137 changes: 137 additions & 0 deletions packages/opencode/src/plugin/command-service.ts
Original file line number Diff line number Diff line change
@@ -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<MessageV2.WithParts> {
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,
}
}
}
94 changes: 94 additions & 0 deletions packages/opencode/src/plugin/command.ts
Original file line number Diff line number Diff line change
@@ -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> | 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<string, Entry> = {}
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<PluginCommandOutput | undefined> {
const entry = await get(name)
if (!entry) return undefined
if (entry.mode !== "plugin") return undefined
if (!entry.execute) return undefined
return entry.execute(input)
}
}
18 changes: 15 additions & 3 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ import { CopilotAuthPlugin } from "./copilot"

export namespace Plugin {
const log = Log.create({ service: "plugin" })
const names = new WeakMap<Hooks, string>()

const BUILTIN = ["[email protected]", "@gitlab/[email protected]"]

// 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",
Expand All @@ -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 ?? [])]
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
}

Expand All @@ -96,7 +104,7 @@ export namespace Plugin {
})

export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "command">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
Expand All @@ -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()
Expand Down
Loading