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 }