diff --git a/packages/docs/docs.json b/packages/docs/docs.json
index 4461f8253b7..4f5b2adefab 100644
--- a/packages/docs/docs.json
+++ b/packages/docs/docs.json
@@ -17,6 +17,10 @@
"group": "Getting started",
"pages": ["index", "quickstart", "development"],
"openapi": "https://opencode.ai/openapi.json"
+ },
+ {
+ "group": "Extending OpenCode",
+ "pages": ["plugins"]
}
]
}
diff --git a/packages/docs/plugins.mdx b/packages/docs/plugins.mdx
new file mode 100644
index 00000000000..ae3b560c247
--- /dev/null
+++ b/packages/docs/plugins.mdx
@@ -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)
+
+This feature is experimental and must be enabled with `experimental.pluginCommands: true`
+
+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
diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index 976f1cd51e9..6df953c5aab 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -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 = {
@@ -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, "template"> & { template: Promise | string }
+ export type Info = Omit, "template" | "execute"> & {
+ template: Promise | string
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ execute?: (...args: any[]) => Promise
+ }
export function hints(template: string): string[] {
const result: string[] = []
@@ -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
})
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index bf4a6035bd8..197abc03382 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -1041,6 +1041,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(),
})
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index 56fe4d13e66..3a4f6771a96 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -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()
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 345b1c49e65..c19ff7c8d15 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1593,6 +1593,80 @@ NOTE: At any point in time through this workflow you should feel free to ask the
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) ?? []
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..191c77c0a5c
--- /dev/null
+++ b/packages/opencode/test/command/plugin-commands.test.ts
@@ -0,0 +1,402 @@
+import { test, expect, describe, mock } from "bun:test"
+import path from "path"
+
+// === Mocks ===
+// Mock BunProc to prevent real package installations
+mock.module("../../src/bun/index", () => ({
+ BunProc: {
+ install: async (pkg: string) => pkg,
+ run: async () => {
+ throw new Error("BunProc.run should not be called in tests")
+ },
+ which: () => process.execPath,
+ InstallFailedError: class extends Error {},
+ },
+}))
+
+// Mock default plugins to prevent loading
+const mockPlugin = () => ({})
+mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
+mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
+
+// Import after mocks are set up
+const { tmpdir } = await import("../fixture/fixture")
+const { Instance } = await import("../../src/project/instance")
+const { Command } = await import("../../src/command")
+const { Plugin } = await import("../../src/plugin")
+const { ToolRegistry } = await import("../../src/tool/registry")
+
+describe("Plugin Commands (experimental.pluginCommands)", () => {
+ describe("Command.list()", () => {
+ test("includes plugin tools with command: true when experimental flag enabled", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Create config with experimental flag enabled
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: {
+ pluginCommands: true,
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Mock Plugin.list() to return a plugin with command: true
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "test-command": {
+ description: "A test command",
+ args: {},
+ execute: async () => "test result",
+ command: true,
+ directExecution: true,
+ },
+ },
+ },
+ ]
+
+ try {
+ const commands = await Command.list()
+ const cmd = commands.find((c) => c.name === "plugin:test-command")
+
+ expect(cmd).toBeDefined()
+ expect(cmd?.description).toBe("A test command")
+ expect(cmd?.pluginCommand).toBe(true)
+ expect(cmd?.directExecution).toBe(true)
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+
+ test("uses plugin: prefix for plugin command names", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: { pluginCommands: true },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "my-tool": {
+ description: "Tool with prefix",
+ args: {},
+ execute: async () => "result",
+ command: true,
+ },
+ },
+ },
+ ]
+
+ try {
+ const commands = await Command.list()
+
+ // Should have plugin: prefix
+ const withPrefix = commands.find((c) => c.name === "plugin:my-tool")
+ expect(withPrefix).toBeDefined()
+
+ // Should NOT have the tool without prefix
+ const withoutPrefix = commands.find((c) => c.name === "my-tool")
+ expect(withoutPrefix).toBeUndefined()
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+
+ test("excludes plugin commands when experimental flag disabled", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Config WITHOUT experimental.pluginCommands
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "hidden-command": {
+ description: "Should not appear",
+ args: {},
+ execute: async () => "result",
+ command: true,
+ },
+ },
+ },
+ ]
+
+ try {
+ const commands = await Command.list()
+
+ // Should NOT include plugin command when flag is disabled
+ const cmd = commands.find((c) => c.name === "plugin:hidden-command")
+ expect(cmd).toBeUndefined()
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+
+ test("excludes plugin tools without command property", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: { pluginCommands: true },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "tool-only": {
+ description: "Tool without command property",
+ args: {},
+ execute: async () => "result",
+ // No command: true property
+ },
+ },
+ },
+ ]
+
+ try {
+ const commands = await Command.list()
+
+ // Should NOT include tool without command: true
+ const cmd = commands.find((c) => c.name === "plugin:tool-only")
+ expect(cmd).toBeUndefined()
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+ })
+
+ describe("Direct Execution", () => {
+ test("command with directExecution: true has execute function", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: { pluginCommands: true },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "direct-tool": {
+ description: "Direct execution tool",
+ args: {},
+ execute: async () => "direct result",
+ command: true,
+ directExecution: true,
+ },
+ },
+ },
+ ]
+
+ try {
+ const commands = await Command.list()
+
+ const cmd = commands.find((c) => c.name === "plugin:direct-tool")
+ expect(cmd).toBeDefined()
+ expect(cmd?.directExecution).toBe(true)
+ expect(cmd?.execute).toBeDefined()
+ expect(typeof cmd?.execute).toBe("function")
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+
+ test("command with directExecution: false does not have directExecution flag set to true", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: { pluginCommands: true },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "ai-tool": {
+ description: "AI execution tool",
+ args: {},
+ execute: async () => "ai result",
+ command: true,
+ directExecution: false,
+ },
+ },
+ },
+ ]
+
+ try {
+ const commands = await Command.list()
+
+ const cmd = commands.find((c) => c.name === "plugin:ai-tool")
+ expect(cmd).toBeDefined()
+ expect(cmd?.directExecution).toBe(false)
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+ })
+
+ describe("Backwards Compatibility", () => {
+ test("plugins without command property still work as tools in registry", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: { pluginCommands: true },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "legacy-tool": {
+ description: "Legacy tool without command property",
+ args: {},
+ execute: async () => "legacy result",
+ },
+ },
+ },
+ ]
+
+ try {
+ // Initialize tool registry
+ await ToolRegistry.state()
+
+ // Tool should be registered in ToolRegistry
+ const registryState = await ToolRegistry.state()
+ const legacyTool = registryState.custom.find((t) => t.id === "legacy-tool")
+ expect(legacyTool).toBeDefined()
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+
+ test("plugin tools are still registered in ToolRegistry even with command: true", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: { pluginCommands: true },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const originalList = Plugin.list
+ Plugin.list = async () => [
+ {
+ tool: {
+ "dual-tool": {
+ description: "Tool that is both command and tool",
+ args: {},
+ execute: async () => "dual result",
+ command: true,
+ directExecution: true,
+ },
+ },
+ },
+ ]
+
+ try {
+ await ToolRegistry.state()
+
+ // Should be in ToolRegistry
+ const registryState = await ToolRegistry.state()
+ const dualTool = registryState.custom.find((t) => t.id === "dual-tool")
+ expect(dualTool).toBeDefined()
+
+ // Should also be in Command.list()
+ const commands = await Command.list()
+ const cmd = commands.find((c) => c.name === "plugin:dual-tool")
+ expect(cmd).toBeDefined()
+ } finally {
+ Plugin.list = originalList
+ }
+ },
+ })
+ })
+ })
+})
diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts
index f759c07d2b5..6ff00f08d09 100644
--- a/packages/plugin/src/tool.ts
+++ b/packages/plugin/src/tool.ts
@@ -20,6 +20,10 @@ export function tool(input: {
description: string
args: Args
execute(args: z.infer>, context: ToolContext): Promise
+ /** Expose this tool as a slash command in the autocomplete (requires experimental.pluginCommands) */
+ command?: boolean
+ /** Execute directly without AI processing (only applies when command is true) */
+ directExecution?: boolean
}) {
return input
}