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
33 changes: 32 additions & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"

Expand All @@ -27,7 +28,10 @@ export namespace Command {
agent: z.string().optional(),
model: z.string().optional(),
template: z.string(),
type: z.enum(["template", "plugin"]).default("template"),
subtask: z.boolean().optional(),
sessionOnly: z.boolean().optional(),
aliases: z.array(z.string()).optional(),
})
.meta({
ref: "Command",
Expand All @@ -45,11 +49,13 @@ export namespace Command {
const result: Record<string, Info> = {
[Default.INIT]: {
name: Default.INIT,
type: "template",
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
},
[Default.REVIEW]: {
name: Default.REVIEW,
type: "template",
description: "review changes [commit|branch|pr], defaults to uncommitted",
template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
subtask: true,
Expand All @@ -59,6 +65,7 @@ export namespace Command {
for (const [name, command] of Object.entries(cfg.command ?? {})) {
result[name] = {
name,
type: "template",
agent: command.agent,
model: command.model,
description: command.description,
Expand All @@ -67,11 +74,35 @@ export namespace Command {
}
}

// Plugin commands
const plugins = await Plugin.list()
for (const plugin of plugins) {
const commands = plugin["plugin.command"]
if (!commands) continue
for (const [name, cmd] of Object.entries(commands)) {
if (result[name]) continue // Don't override existing commands
result[name] = {
name,
type: "plugin",
description: cmd.description,
template: "", // Plugin commands don't use templates
sessionOnly: cmd.sessionOnly,
aliases: cmd.aliases,
}
}
}

return result
})

export async function get(name: string) {
return state().then((x) => x[name])
const commands = await state()
if (commands[name]) return commands[name]
// Resolve aliases
for (const cmd of Object.values(commands)) {
if (cmd.aliases?.includes(name)) return cmd
}
return undefined
}

export async function list() {
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export namespace Plugin {
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (typeof fn !== "function") continue
const init = await fn(input)
hooks.push(init)
}
Expand All @@ -53,7 +54,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" | "plugin.command">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
Expand All @@ -73,6 +74,10 @@ export namespace Plugin {
return state().then((x) => x.hooks)
}

export async function client() {
return state().then((x) => x.input.client)
}

export async function init() {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,77 @@ export namespace SessionPrompt {
export async function command(input: CommandInput) {
log.info("command", input)
const command = await Command.get(input.command)
if (!command) {
log.warn("command not found", { command: input.command })
return
}

if (command.sessionOnly) {
try {
await Session.get(input.sessionID)
} catch (error) {
const message = `/${command.name} requires an existing session`
log.warn("session-only command blocked", {
command: command.name,
sessionID: input.sessionID,
error,
})
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({
message,
}).toObject(),
})
throw new Error(message)
}
}

// Plugin commands execute directly via hook
if (command.type === "plugin") {
const plugins = await Plugin.list()
for (const plugin of plugins) {
const pluginCommands = plugin["plugin.command"]
const pluginCommand = pluginCommands?.[command.name]
if (!pluginCommand) continue

const messagesBefore = await Session.messages({ sessionID: input.sessionID, limit: 1 })
const lastMessageIDBefore = messagesBefore[0]?.info.id

try {
const client = await Plugin.client()
await pluginCommand.execute({
sessionID: input.sessionID,
arguments: input.arguments,
client,
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
log.error("plugin command failed", { command: command.name, error: message })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({
message: `/${command.name} failed: ${message}`,
}).toObject(),
})
throw error
}

// Emit event if plugin created a new message
const messagesAfter = await Session.messages({ sessionID: input.sessionID, limit: 1 })
if (messagesAfter.length > 0 && messagesAfter[0].info.id !== lastMessageIDBefore) {
Bus.publish(Command.Event.Executed, {
name: command.name,
sessionID: input.sessionID,
arguments: input.arguments,
messageID: messagesAfter[0].info.id,
})
return messagesAfter[0]
}
return
}
return
}

const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())

const raw = input.arguments.match(argsRegex) ?? []
Expand Down
157 changes: 157 additions & 0 deletions packages/opencode/test/command/plugin-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { test, expect, mock } from "bun:test"
import { tmpdir } from "../fixture/fixture"

const pluginModulePath = new URL("../../src/plugin/index.ts", import.meta.url).pathname

let pluginHook: Record<string, any> = {}
const executeCalls: Array<Record<string, unknown>> = []
const fakeClient = {
tui: {
publish: async () => {},
},
}

mock.module(pluginModulePath, () => ({
Plugin: {
list: async () => [pluginHook],
client: async () => fakeClient,
trigger: async (_name: string, _input: unknown, output: unknown) => output,
},
}))

const { Instance } = await import("../../src/project/instance")
const { Session } = await import("../../src/session")
const { SessionPrompt } = await import("../../src/session/prompt")
const { Command } = await import("../../src/command")
const { Bus } = await import("../../src/bus")
const { Identifier } = await import("../../src/id/id")

async function withInstance(fn: () => Promise<void>) {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
await Instance.dispose()
},
})
}

test("Command.get resolves plugin aliases", async () => {
pluginHook = {
"plugin.command": {
hello: {
description: "hello",
aliases: ["hi"],
sessionOnly: false,
execute: async () => {},
},
},
}

await withInstance(async () => {
const cmd = await Command.get("hi")
expect(cmd?.name).toBe("hello")
expect(cmd?.type).toBe("plugin")
})
})

test("SessionPrompt.command executes plugin command", async () => {
executeCalls.length = 0
pluginHook = {
"plugin.command": {
hello: {
description: "hello",
sessionOnly: false,
execute: async (input: { sessionID: string; arguments: string }) => {
executeCalls.push(input)
},
},
},
}

await withInstance(async () => {
const session = await Session.create({})
await SessionPrompt.command({
sessionID: session.id,
command: "hello",
arguments: "world",
})
expect(executeCalls.length).toBe(1)
expect(executeCalls[0].arguments).toBe("world")
})
})

test("SessionPrompt.command publishes error on plugin failure", async () => {
pluginHook = {
"plugin.command": {
boom: {
description: "boom",
sessionOnly: false,
execute: async () => {
throw new Error("boom")
},
},
},
}

await withInstance(async () => {
const session = await Session.create({})
const errors: Array<{ type: string; properties: any }> = []
const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => {
errors.push(event)
})

await expect(
SessionPrompt.command({
sessionID: session.id,
command: "boom",
arguments: "",
}),
).rejects.toThrow("boom")

await new Promise((resolve) => setTimeout(resolve, 0))
unsubscribe()

expect(errors.length).toBe(1)
expect(JSON.stringify(errors[0].properties.error)).toContain("/boom failed")
})
})

test("SessionPrompt.command blocks session-only commands for missing sessions", async () => {
executeCalls.length = 0
pluginHook = {
"plugin.command": {
hello: {
description: "hello",
sessionOnly: true,
execute: async (input: { sessionID: string; arguments: string }) => {
executeCalls.push(input)
},
},
},
}

await withInstance(async () => {
const missingSessionID = Identifier.ascending("session")
const errors: Array<{ type: string; properties: any }> = []
const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => {
errors.push(event)
})

await expect(
SessionPrompt.command({
sessionID: missingSessionID,
command: "hello",
arguments: "",
}),
).rejects.toThrow("requires an existing session")

await new Promise((resolve) => setTimeout(resolve, 0))
unsubscribe()

expect(executeCalls.length).toBe(0)
expect(errors.length).toBe(1)
expect(JSON.stringify(errors[0].properties.error)).toContain("/hello requires an existing session")
})
})
15 changes: 15 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,19 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Register custom slash commands (accessible via /command in TUI/web)
*/
"plugin.command"?: {
[name: string]: {
description: string
aliases?: string[]
sessionOnly?: boolean
execute(input: {
sessionID: string
arguments: string
client: ReturnType<typeof createOpencodeClient>
}): Promise<void>
}
}
}
3 changes: 3 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1709,7 +1709,10 @@ export type Command = {
agent?: string
model?: string
template: string
type?: "template" | "plugin"
subtask?: boolean
sessionOnly?: boolean
aliases?: Array<string>
}

export type Model = {
Expand Down