diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index db49b0f4fc5b..e662a2b89052 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -12,6 +12,7 @@ import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" +import { Plugin } from "../plugin" export namespace Agent { export const Info = z @@ -160,6 +161,34 @@ export namespace Agent { }, } + // Load plugin agents (can override built-ins, but will be overridden by config) + const pluginAgents = await Plugin.agents() + for (const [key, value] of Object.entries(pluginAgents)) { + let item = result[key] + if (!item) { + item = result[key] = { + name: key, + mode: value.mode ?? "all", + permission: PermissionNext.merge(defaults, user), + options: {}, + native: false, + } + } + if (value.model) item.model = Provider.parseModel(value.model) + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + if (value.permission) { + item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission)) + } + } + + // Config-based agents override plugin agents for (const [key, value] of Object.entries(cfg.agent ?? {})) { if (value.disable) { delete result[key] diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e96..bafb3bc6578c 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 = { @@ -58,6 +59,7 @@ export namespace Command { const state = Instance.state(async () => { const cfg = await Config.get() + // Start with built-in commands const result: Record = { [Default.INIT]: { name: Default.INIT, @@ -78,6 +80,23 @@ export namespace Command { }, } + // Load plugin commands first (lowest priority, will be overridden by config/file-based) + const pluginCommands = await Plugin.commands() + for (const [name, command] of Object.entries(pluginCommands)) { + result[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + get template() { + return command.template + }, + subtask: command.subtask, + hints: hints(command.template), + } + } + + // Config-based commands override plugin commands for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { name, @@ -91,6 +110,8 @@ export namespace Command { hints: hints(command.template), } } + + // MCP prompts for (const [name, prompt] of Object.entries(await MCP.prompts())) { result[name] = { name, diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 4c42ab972e3e..18bb16e337d0 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,4 +1,11 @@ -import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin" +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + CommandDefinition, + SkillDefinition, + AgentDefinition, +} from "@opencode-ai/plugin" import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" @@ -20,7 +27,13 @@ export namespace Plugin { fetch: async (...args) => Server.App().fetch(...args), }) const config = await Config.get() - const hooks = [] + const hooks: Hooks[] = [] + + // Collect extensions from plugins + const commands: Record = {} + const skills: Record = {} + const agents: Record = {} + const input: PluginInput = { client, project: Instance.project, @@ -50,17 +63,49 @@ export namespace Plugin { for (const [_name, fn] of Object.entries(mod)) { const init = await fn(input) hooks.push(init) + + // Extract extension definitions from plugin + if (init.command) { + for (const [name, def] of Object.entries(init.command)) { + if (commands[name]) { + log.warn("duplicate plugin command", { name, existing: "plugin" }) + } + log.debug("registered plugin command", { name }) + commands[name] = def + } + } + if (init.skill) { + for (const [name, def] of Object.entries(init.skill)) { + if (skills[name]) { + log.warn("duplicate plugin skill", { name, existing: "plugin" }) + } + log.debug("registered plugin skill", { name }) + skills[name] = def + } + } + if (init.agent) { + for (const [name, def] of Object.entries(init.agent)) { + if (agents[name]) { + log.warn("duplicate plugin agent", { name, existing: "plugin" }) + } + log.debug("registered plugin agent", { name }) + agents[name] = def + } + } } } return { hooks, input, + commands, + skills, + agents, } }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "command" | "skill" | "agent">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -96,4 +141,16 @@ export namespace Plugin { } }) } + + export async function commands() { + return state().then((x) => x.commands) + } + + export async function skills() { + return state().then((x) => x.skills) + } + + export async function agents() { + return state().then((x) => x.agents) + } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index bf90dd5870cb..25b424d68ab1 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -7,6 +7,7 @@ import { Log } from "../util/log" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { exists } from "fs/promises" +import { Plugin } from "../plugin" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -41,6 +42,16 @@ export namespace Skill { export const state = Instance.state(async () => { const skills: Record = {} + // Load plugin skills first (lowest priority, will be overridden by file-based) + const pluginSkills = await Plugin.skills() + for (const [name, skill] of Object.entries(pluginSkills)) { + skills[name] = { + name: skill.name, + description: skill.description, + location: `plugin:${name}`, + } + } + const addSkill = async (match: string) => { const md = await ConfigMarkdown.parse(match) if (!md) { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 00a081eaca03..b2d151a0b2db 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -3,6 +3,10 @@ import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" import { ConfigMarkdown } from "../config/markdown" +import { Plugin } from "../plugin" +import { Log } from "../util/log" + +const log = Log.create({ service: "tool.skill" }) export const SkillTool = Tool.define("skill", async () => { const skills = await Skill.all() @@ -57,12 +61,23 @@ export const SkillTool = Tool.define("skill", async () => { always: [params.name], metadata: {}, }) - // Load and parse skill content - const parsed = await ConfigMarkdown.parse(skill.location) - const dir = path.dirname(skill.location) + + const { content, dir } = await (async () => { + if (skill.location.startsWith("plugin:")) { + const pluginSkills = await Plugin.skills() + const skillDef = pluginSkills[skill.name] + if (!skillDef) { + log.error("plugin skill content unavailable", { name: params.name, location: skill.location }) + throw new Error(`Plugin skill "${params.name}" content not available`) + } + return { content: skillDef.content, dir: "(plugin-provided)" } + } + const parsed = await ConfigMarkdown.parse(skill.location) + return { content: parsed.content.trim(), dir: path.dirname(skill.location) } + })() // Format output similar to plugin pattern - const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n") + const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content].join("\n") return { title: `Loaded skill: ${skill.name}`, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 82ec0bdfdc0a..908a4f69166d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -10,7 +10,10 @@ }, "exports": { ".": "./src/index.ts", - "./tool": "./src/tool.ts" + "./tool": "./src/tool.ts", + "./command": "./src/command.ts", + "./skill": "./src/skill.ts", + "./agent": "./src/agent.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/agent.ts b/packages/plugin/src/agent.ts new file mode 100644 index 000000000000..5b069123b6ba --- /dev/null +++ b/packages/plugin/src/agent.ts @@ -0,0 +1,26 @@ +export type AgentDefinition = { + description?: string + prompt?: string + model?: string + mode?: "subagent" | "primary" | "all" + temperature?: number + top_p?: number + color?: string + steps?: number + permission?: Record> + options?: Record +} + +/** + * Helper for defining an agent with type safety. + * @example + * agent({ + * description: "Expert code reviewer", + * model: "anthropic/claude-sonnet-4-5", + * mode: "subagent", + * prompt: "You are a code review expert..." + * }) + */ +export function agent(input: AgentDefinition): AgentDefinition { + return input +} diff --git a/packages/plugin/src/command.ts b/packages/plugin/src/command.ts new file mode 100644 index 000000000000..010d28408bbc --- /dev/null +++ b/packages/plugin/src/command.ts @@ -0,0 +1,20 @@ +export type CommandDefinition = { + template: string + description?: string + agent?: string + model?: string + subtask?: boolean +} + +/** + * Helper for defining a command with type safety. + * @example + * command({ + * template: "Analyze the code in $1", + * description: "Run code analysis", + * agent: "build" + * }) + */ +export function command(input: CommandDefinition): CommandDefinition { + return input +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 5653f19d9125..cb3b7105980c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -14,8 +14,14 @@ import type { import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" +import { type CommandDefinition } from "./command" +import { type SkillDefinition } from "./skill" +import { type AgentDefinition } from "./agent" export * from "./tool" +export * from "./command" +export * from "./skill" +export * from "./agent" export type ProviderContext = { source: "env" | "config" | "custom" | "api" @@ -150,6 +156,24 @@ export interface Hooks { [key: string]: ToolDefinition } auth?: AuthHook + /** + * Register custom commands that appear as slash commands in the TUI. + * Keys become command names (e.g., { "my-cmd": {...} } creates /my-cmd) + * File-based commands with the same name will override these. + */ + command?: Record + /** + * Register skills that agents can load on-demand via the skill tool. + * Keys become skill names. + * File-based skills with the same name will override these. + */ + skill?: Record + /** + * Register custom agents for specialized tasks. + * Keys become agent names. + * File-based agents with the same name will override these. + */ + agent?: Record /** * Called when a new message is received */ diff --git a/packages/plugin/src/skill.ts b/packages/plugin/src/skill.ts new file mode 100644 index 000000000000..e6450d109cca --- /dev/null +++ b/packages/plugin/src/skill.ts @@ -0,0 +1,18 @@ +export type SkillDefinition = { + name: string + description: string + content: string +} + +/** + * Helper for defining a skill with type safety. + * @example + * skill({ + * name: "my-skill", + * description: "Does something useful", + * content: `# My Skill\n\nInstructions here...` + * }) + */ +export function skill(input: SkillDefinition): SkillDefinition { + return input +}