diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0a9bfc62030..a037408391d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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" @@ -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", @@ -45,11 +49,13 @@ export namespace Command { const result: Record = { [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, @@ -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, @@ -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() { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..586d13e6bfd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -41,6 +41,7 @@ export namespace Plugin { } const mod = await import(plugin) for (const [_name, fn] of Object.entries(mod)) { + if (typeof fn !== "function") continue const init = await fn(input) hooks.push(init) } @@ -53,7 +54,7 @@ export namespace Plugin { }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "plugin.command">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -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() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 19dc90b3bcb..c5d3654c051 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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) ?? [] 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..90913a59eae --- /dev/null +++ b/packages/opencode/test/command/plugin-commands.test.ts @@ -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 = {} +const executeCalls: Array> = [] +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) { + 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") + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index fbc0e710c83..09106d20817 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -206,4 +206,19 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * 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 + }): Promise + } + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90b2154e18a..06b5c3151ca 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1709,7 +1709,10 @@ export type Command = { agent?: string model?: string template: string + type?: "template" | "plugin" subtask?: boolean + sessionOnly?: boolean + aliases?: Array } export type Model = {