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
4 changes: 4 additions & 0 deletions packages/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"group": "Getting started",
"pages": ["index", "quickstart", "development"],
"openapi": "https://opencode.ai/openapi.json"
},
{
"group": "Extending OpenCode",
"pages": ["plugins"]
}
]
}
Expand Down
139 changes: 139 additions & 0 deletions packages/docs/plugins.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: Plugins
description: Extend OpenCode with custom plugins
---

# Plugins

OpenCode supports plugins that can extend its functionality with custom tools, event handlers, and commands.

## Creating a Plugin

A plugin is a JavaScript/TypeScript module that exports a function returning a `Hooks` object:

```typescript
import { tool } from "@opencode-ai/plugin"

export const MyPlugin = async () => ({
tool: {
"my-tool": tool({
description: "Description of what this tool does",
args: {
// Zod schema for arguments
},
async execute(args, context) {
// Tool implementation
return "Result string"
},
}),
},
})
```

## Installing Plugins

Add plugins to your `opencode.json` or `opencode.jsonc` configuration:

```json
{
"plugin": ["my-plugin-package", "file:./local-plugin"]
}
```

## Plugin Commands (Experimental)

<Warning>This feature is experimental and must be enabled with `experimental.pluginCommands: true`</Warning>

Plugin tools can optionally be exposed as slash commands that users can invoke directly from the command input.

### Enabling Plugin Commands

Add to your `opencode.json`:

```json
{
"experimental": {
"pluginCommands": true
}
}
```

### Creating a Plugin Command

Add the `command` and `directExecution` properties to your tool definition:

```typescript
import { tool } from "@opencode-ai/plugin"

export const MyPlugin = async () => ({
tool: {
"my-command": {
...tool({
description: "A command that users can invoke directly",
args: {},
async execute(args, context) {
return "Command result displayed to user"
},
}),
// Expose as slash command in autocomplete
command: true,
// Execute directly without AI processing
directExecution: true,
},
},
})
```

### Command Properties

| Property | Type | Default | Description |
| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `command` | `boolean` | `false` | When `true`, the tool appears in the `/` command autocomplete with a `plugin:` prefix |
| `directExecution` | `boolean` | `false` | When `true`, the command executes directly without AI processing. When `false`, the command is sent to the AI as a prompt |

### Using Plugin Commands

Once enabled, plugin commands appear in the autocomplete when you type `/`. They are prefixed with `plugin:` to distinguish them from built-in commands:

```
/plugin:my-command
```

### Execution Modes

**Direct Execution** (`directExecution: true`):

- Command runs immediately without AI involvement
- Result is displayed directly to the user
- Faster execution, no token usage
- Best for: status checks, statistics, simple queries

**AI-Assisted** (`directExecution: false`):

- Command template is sent to the AI
- AI processes and responds
- Can leverage AI capabilities
- Best for: complex tasks requiring AI reasoning

## Event Handlers

Plugins can also subscribe to OpenCode events:

```typescript
export const MyPlugin = async () => ({
event: {
"command.executed": async (event) => {
// Handle command execution events
console.log(`Command ${event.command} was executed`)
},
},
})
```

## Best Practices

1. **Use descriptive names**: Tool and command names should clearly indicate their purpose
2. **Provide helpful descriptions**: Descriptions help users understand what the tool does
3. **Handle errors gracefully**: Return meaningful error messages
4. **Keep direct execution fast**: Direct execution commands should complete quickly
5. **Use AI-assisted mode for complex tasks**: Let the AI handle tasks requiring reasoning
35 changes: 34 additions & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp"
import { Plugin } from "../plugin"

export namespace Command {
export const Event = {
Expand All @@ -32,13 +33,21 @@ export namespace Command {
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
// Plugin command properties (experimental.pluginCommands)
pluginCommand: z.boolean().optional(),
directExecution: z.boolean().optional(),
execute: z.function().optional(),
})
.meta({
ref: "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 Info = Omit<z.infer<typeof Info>, "template" | "execute"> & {
template: Promise<string> | string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute?: (...args: any[]) => Promise<string>
}

export function hints(template: string): string[] {
const result: string[] = []
Expand Down Expand Up @@ -118,6 +127,30 @@ export namespace Command {
}
}

// Add plugin tools as commands (experimental.pluginCommands)
if (cfg.experimental?.pluginCommands) {
const plugins = await Plugin.list()
for (const plugin of plugins) {
for (const [toolName, def] of Object.entries(plugin.tool ?? {})) {
if (def.command) {
const commandName = `plugin:${toolName}`
result[commandName] = {
name: commandName,
description: def.description,
pluginCommand: true,
directExecution: def.directExecution ?? false,
execute: def.execute,
get template() {
// For non-direct execution, create a template that invokes the tool
return `Use the ${toolName} tool to help the user.`
},
hints: [],
}
}
}
}
}

return result
})

Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,10 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
pluginCommands: z
.boolean()
.optional()
.describe("Enable plugin tools as slash commands with direct execution"),
})
.optional(),
})
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { ToolRegistry } from "../tool/registry"

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
// Initialize tool registry after plugins to ensure plugin tools are registered
await ToolRegistry.state()
Share.init()
ShareNext.init()
Format.init()
Expand Down
74 changes: 74 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,80 @@ export namespace SessionPrompt {
export async function command(input: CommandInput) {
log.info("command", input)
const command = await Command.get(input.command)

// Handle plugin commands with direct execution (experimental.pluginCommands)
if (command.directExecution && command.pluginCommand && command.execute) {
log.info("executing plugin command directly", { command: input.command })
const startTime = Date.now()
try {
// Parse arguments for the plugin tool
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))

// Execute the plugin tool directly without AI
const result = await command.execute({ args: args.join(" ") }, {})

// Create message and part IDs
const messageID = Identifier.ascending("message")
const partID = Identifier.ascending("part")

// Create a minimal assistant message for the result
const message: MessageV2.Assistant = {
id: messageID,
sessionID: input.sessionID,
role: "assistant",
time: {
created: startTime,
completed: Date.now(),
},
parentID: "",
modelID: "plugin",
providerID: "plugin",
mode: "plugin",
agent: "plugin",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
}

// Create a text part with the result
const part: MessageV2.TextPart = {
id: partID,
sessionID: input.sessionID,
messageID: messageID,
type: "text",
text: result,
time: {
start: startTime,
end: Date.now(),
},
}

// Persist and publish the message and part
await Session.updateMessage(message)
await Session.updatePart(part)

log.info("plugin command executed successfully", { command: input.command })
return { info: message, parts: [part] }
} catch (error) {
log.error("plugin command execution failed", { command: input.command, error })
const errorMessage = error instanceof Error ? error.message : String(error)
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({ message: `Plugin command failed: ${errorMessage}` }).toObject(),
})
return
}
}

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

const raw = input.arguments.match(argsRegex) ?? []
Expand Down
Loading