diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e9..c011d16879e 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -9,6 +9,7 @@ import { MCP } from "../mcp" export namespace Command { export const Event = { + /** @deprecated Use `command.execute.after` hook instead */ Executed: BusEvent.define( "command.executed", z.object({ @@ -18,6 +19,15 @@ export namespace Command { messageID: Identifier.schema("message"), }), ), + After: BusEvent.define( + "command.execute.after", + z.object({ + sessionID: Identifier.schema("session"), + command: z.string(), + arguments: z.string(), + messageID: Identifier.schema("message"), + }), + ), } export const Info = z diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b635cee7fb9..1f6284817d9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1330,6 +1330,7 @@ export namespace SessionPrompt { arguments: z.string(), command: z.string(), variant: z.string().optional(), + stop: z.boolean().optional(), }) export type CommandInput = z.infer const bashRegex = /!`([^`]+)`/g @@ -1344,7 +1345,33 @@ export namespace SessionPrompt { export async function command(input: CommandInput) { log.info("command", input) + input = await Plugin.trigger("command.execute.before", input, { ...input, stop: false }) + if (input.stop) { + log.info("command stopped by plugin", { command: input.command }) + return undefined + } + if (!input.command) { + const error = new NamedError.Unknown({ + message: "Plugin hook did not provide required command", + }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } + 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 agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] @@ -1453,6 +1480,12 @@ export namespace SessionPrompt { arguments: input.arguments, messageID: result.info.id, }) + Bus.publish(Command.Event.After, { + sessionID: input.sessionID, + command: input.command, + arguments: input.arguments, + messageID: result.info.id, + }) return result } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 5653f19d912..beaa2617b96 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -157,6 +157,43 @@ export interface Hooks { input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string }; messageID?: string }, output: { message: UserMessage; parts: Part[] }, ) => Promise + /** + * Called before a command is executed. Allows plugins to modify the command + * or prevent it from executing entirely. + * + * - `stop`: If true, prevents the command from executing + * - `command`, `arguments`, `agent`, `model`: Can be modified to change the command + */ + "command.execute.before"?: ( + input: { + sessionID: string + command: string + arguments: string + agent?: string + model?: string + messageID?: string + variant?: string + }, + output: { + stop: boolean + command?: string + arguments?: string + agent?: string + model?: string + }, + ) => Promise + /** + * Called after a command is executed and sent to the LLM. + * This is the preferred hook over `command.executed` event. + * + * - `command.executed` is deprecated but still supported for backward compatibility + */ + "command.execute.after"?: (input: { + sessionID: string + command: string + arguments: string + messageID: string + }) => Promise /** * Modify parameters sent to LLM */ diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 463ad9e498f..6ac5d423bc7 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -321,3 +321,7 @@ Custom commands can override built-in commands. ::: If you define a custom command with the same name, it will override the built-in command. + +:::note +Plugins can intercept commands before execution using the [`command.execute.before` hook](/docs/plugins#command-hooks). +::: diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index 59a7010833d..3595d6bccc3 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -145,7 +145,9 @@ Plugins can subscribe to events as seen below in the Examples section. Here is a #### Command Events -- `command.executed` +- `command.executed` (deprecated, use `command.execute.after`) +- `command.execute.before` +- `command.execute.after` #### File Events @@ -340,3 +342,71 @@ Format as a structured prompt that a new agent can use to resume work. ``` When `output.prompt` is set, it completely replaces the default compaction prompt. The `output.context` array is ignored in this case. + +--- + +### Command hooks + +Intercept and modify slash commands before they're sent to the LLM: + +```ts title=".opencode/plugin/command-hooks.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CommandHooksPlugin: Plugin = async (ctx) => { + return { + "command.execute.before": async (input, output) => { + // Prevent /init command from running + if (input.command === "init") { + console.log("Blocking /init command") + output.stop = true + return + } + + // Add --verbose flag to /review command + if (input.command === "review") { + output.arguments = input.arguments + " --verbose" + } + + // Force specific model for code review + if (input.command === "review") { + output.model = "anthropic/claude-3-5-sonnet-20241022" + } + + // Change agent for /test command + if (input.command === "test") { + output.agent = "build" + } + }, + } +} +``` + +The `command.execute.before` hook allows you to: + +- **Block commands** - Set `output.stop = true` to prevent command execution +- **Modify arguments** - Change `output.arguments` to alter command arguments +- **Change agent** - Override the agent used for a command +- **Change model** - Override the model used for a command +- **Create aliases** - Map one command to another + +:::note +The `output` object is initialized with all values from `input`, so you only need to set the fields you want to change. +::: + +Run code after a command completes: + +```ts title=".opencode/plugin/command-logging.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CommandLoggingPlugin: Plugin = async (ctx) => { + return { + "command.execute.after": async (input) => { + // Log command execution for analytics or auditing + console.log(`Command executed: /${input.command} with args: "${input.arguments}"`) + // Could send to external service, write to file, etc. + }, + } +} +``` + +This hook fires after the command has been processed and sent to the LLM. Use it for logging, analytics, or triggering side effects.