Skip to content
Draft
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
10 changes: 10 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CommandInput>
const bashRegex = /!`([^`]+)`/g
Expand All @@ -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) ?? []
Expand Down Expand Up @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
/**
* 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<void>
/**
* 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<void>
/**
* Modify parameters sent to LLM
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/content/docs/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
:::
72 changes: 71 additions & 1 deletion packages/web/src/content/docs/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.