From ba8144c225be8dec0ddc63cd159ad5d58677df44 Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:24:10 -0800 Subject: [PATCH 1/6] feat(opencode): allow plugin commands without client changes --- packages/opencode/src/command/index.ts | 33 +++++++++++- packages/opencode/src/plugin/index.ts | 7 ++- packages/opencode/src/session/prompt.ts | 68 +++++++++++++++++++++++++ packages/plugin/src/index.ts | 15 ++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0a9bfc62030..a037408391d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -4,6 +4,7 @@ import z from "zod" import { Config } from "../config/config" import { Instance } from "../project/instance" import { Identifier } from "../id/id" +import { Plugin } from "../plugin" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" @@ -27,7 +28,10 @@ export namespace Command { agent: z.string().optional(), model: z.string().optional(), template: z.string(), + type: z.enum(["template", "plugin"]).default("template"), subtask: z.boolean().optional(), + sessionOnly: z.boolean().optional(), + aliases: z.array(z.string()).optional(), }) .meta({ ref: "Command", @@ -45,11 +49,13 @@ export namespace Command { const result: Record = { [Default.INIT]: { name: Default.INIT, + type: "template", description: "create/update AGENTS.md", template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), }, [Default.REVIEW]: { name: Default.REVIEW, + type: "template", description: "review changes [commit|branch|pr], defaults to uncommitted", template: PROMPT_REVIEW.replace("${path}", Instance.worktree), subtask: true, @@ -59,6 +65,7 @@ export namespace Command { for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { name, + type: "template", agent: command.agent, model: command.model, description: command.description, @@ -67,11 +74,35 @@ export namespace Command { } } + // Plugin commands + const plugins = await Plugin.list() + for (const plugin of plugins) { + const commands = plugin["plugin.command"] + if (!commands) continue + for (const [name, cmd] of Object.entries(commands)) { + if (result[name]) continue // Don't override existing commands + result[name] = { + name, + type: "plugin", + description: cmd.description, + template: "", // Plugin commands don't use templates + sessionOnly: cmd.sessionOnly, + aliases: cmd.aliases, + } + } + } + return result }) export async function get(name: string) { - return state().then((x) => x[name]) + const commands = await state() + if (commands[name]) return commands[name] + // Resolve aliases + for (const cmd of Object.values(commands)) { + if (cmd.aliases?.includes(name)) return cmd + } + return undefined } export async function list() { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..586d13e6bfd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -41,6 +41,7 @@ export namespace Plugin { } const mod = await import(plugin) for (const [_name, fn] of Object.entries(mod)) { + if (typeof fn !== "function") continue const init = await fn(input) hooks.push(init) } @@ -53,7 +54,7 @@ export namespace Plugin { }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "plugin.command">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -73,6 +74,10 @@ export namespace Plugin { return state().then((x) => x.hooks) } + export async function client() { + return state().then((x) => x.input.client) + } + export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 19dc90b3bcb..87b78a58fdd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1282,6 +1282,74 @@ export namespace SessionPrompt { export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!command) { + log.warn("command not found", { command: input.command }) + return + } + + if (command.sessionOnly) { + try { + await Session.get(input.sessionID) + } catch (error) { + const message = `/${command.name} requires an existing session` + log.warn("session-only command blocked", { + command: command.name, + sessionID: input.sessionID, + error, + }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ + message, + }).toObject(), + }) + throw new Error(message) + } + } + + // Plugin commands execute directly via hook + if (command.type === "plugin") { + const plugins = await Plugin.list() + for (const plugin of plugins) { + const pluginCommands = plugin["plugin.command"] + const pluginCommand = pluginCommands?.[command.name] + if (!pluginCommand) continue + + try { + const client = await Plugin.client() + await pluginCommand.execute({ + sessionID: input.sessionID, + arguments: input.arguments, + client, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.error("plugin command failed", { command: command.name, error: message }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ + message: `/${command.name} failed: ${message}`, + }).toObject(), + }) + throw error + } + + // Emit event if plugin created a message + const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + if (last.length > 0) { + Bus.publish(Command.Event.Executed, { + name: command.name, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: last[0].info.id, + }) + return last[0] + } + return + } + return + } + const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index fbc0e710c83..09106d20817 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -206,4 +206,19 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Register custom slash commands (accessible via /command in TUI/web) + */ + "plugin.command"?: { + [name: string]: { + description: string + aliases?: string[] + sessionOnly?: boolean + execute(input: { + sessionID: string + arguments: string + client: ReturnType + }): Promise + } + } } From 10a06643fc1cfade8ea9aa7658aa970510a6503a Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:24:16 -0800 Subject: [PATCH 2/6] test(opencode): cover plugin commands and session-only guard --- .../test/command/plugin-commands.test.ts | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 packages/opencode/test/command/plugin-commands.test.ts 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..90913a59eae --- /dev/null +++ b/packages/opencode/test/command/plugin-commands.test.ts @@ -0,0 +1,157 @@ +import { test, expect, mock } from "bun:test" +import { tmpdir } from "../fixture/fixture" + +const pluginModulePath = new URL("../../src/plugin/index.ts", import.meta.url).pathname + +let pluginHook: Record = {} +const executeCalls: Array> = [] +const fakeClient = { + tui: { + publish: async () => {}, + }, +} + +mock.module(pluginModulePath, () => ({ + Plugin: { + list: async () => [pluginHook], + client: async () => fakeClient, + trigger: async (_name: string, _input: unknown, output: unknown) => output, + }, +})) + +const { Instance } = await import("../../src/project/instance") +const { Session } = await import("../../src/session") +const { SessionPrompt } = await import("../../src/session/prompt") +const { Command } = await import("../../src/command") +const { Bus } = await import("../../src/bus") +const { Identifier } = await import("../../src/id/id") + +async function withInstance(fn: () => Promise) { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await fn() + await Instance.dispose() + }, + }) +} + +test("Command.get resolves plugin aliases", async () => { + pluginHook = { + "plugin.command": { + hello: { + description: "hello", + aliases: ["hi"], + sessionOnly: false, + execute: async () => {}, + }, + }, + } + + await withInstance(async () => { + const cmd = await Command.get("hi") + expect(cmd?.name).toBe("hello") + expect(cmd?.type).toBe("plugin") + }) +}) + +test("SessionPrompt.command executes plugin command", async () => { + executeCalls.length = 0 + pluginHook = { + "plugin.command": { + hello: { + description: "hello", + sessionOnly: false, + execute: async (input: { sessionID: string; arguments: string }) => { + executeCalls.push(input) + }, + }, + }, + } + + await withInstance(async () => { + const session = await Session.create({}) + await SessionPrompt.command({ + sessionID: session.id, + command: "hello", + arguments: "world", + }) + expect(executeCalls.length).toBe(1) + expect(executeCalls[0].arguments).toBe("world") + }) +}) + +test("SessionPrompt.command publishes error on plugin failure", async () => { + pluginHook = { + "plugin.command": { + boom: { + description: "boom", + sessionOnly: false, + execute: async () => { + throw new Error("boom") + }, + }, + }, + } + + await withInstance(async () => { + const session = await Session.create({}) + const errors: Array<{ type: string; properties: any }> = [] + const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => { + errors.push(event) + }) + + await expect( + SessionPrompt.command({ + sessionID: session.id, + command: "boom", + arguments: "", + }), + ).rejects.toThrow("boom") + + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(errors.length).toBe(1) + expect(JSON.stringify(errors[0].properties.error)).toContain("/boom failed") + }) +}) + +test("SessionPrompt.command blocks session-only commands for missing sessions", async () => { + executeCalls.length = 0 + pluginHook = { + "plugin.command": { + hello: { + description: "hello", + sessionOnly: true, + execute: async (input: { sessionID: string; arguments: string }) => { + executeCalls.push(input) + }, + }, + }, + } + + await withInstance(async () => { + const missingSessionID = Identifier.ascending("session") + const errors: Array<{ type: string; properties: any }> = [] + const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => { + errors.push(event) + }) + + await expect( + SessionPrompt.command({ + sessionID: missingSessionID, + command: "hello", + arguments: "", + }), + ).rejects.toThrow("requires an existing session") + + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(executeCalls.length).toBe(0) + expect(errors.length).toBe(1) + expect(JSON.stringify(errors[0].properties.error)).toContain("/hello requires an existing session") + }) +}) From 65f24afb3643ed145ff812b5af82cae0735774a0 Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:24:21 -0800 Subject: [PATCH 3/6] chore(sdk): keep command types aligned --- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90b2154e18a..06b5c3151ca 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1709,7 +1709,10 @@ export type Command = { agent?: string model?: string template: string + type?: "template" | "plugin" subtask?: boolean + sessionOnly?: boolean + aliases?: Array } export type Model = { From 5ec33e6dae8e18423b161251932a9e8c916541dc Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:24:28 -0800 Subject: [PATCH 4/6] docs(context): capture plugin command plan --- ...plugin-commands-improvements-2025-12-26.md | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md diff --git a/CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md b/CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md new file mode 100644 index 00000000000..8480c13b798 --- /dev/null +++ b/CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md @@ -0,0 +1,399 @@ +# Plan: Plugin Commands Feature + +**Issue Reference:** Based on PR #4411 concepts +**Created:** 2025-12-26 +**Revised:** 2025-12-27 +**Status:** Complete + +--- + +## Overview + +Implement plugin-registered slash commands that execute via the existing `session.command` API entrypoint. Alias resolution and error/event handling are server-side only. This plan avoids any client changes and stays backward compatible with existing clients that already call the command API. + +This plan implements the feature from scratch with fixes for the issues identified in PR #4411's review, while minimizing footprint and aligning with upstream patterns. + +--- + +## Design Decisions + +### sessionOnly Enforcement + +**Definition**: A `sessionOnly` command requires an **existing** session. + +**Enforcement point**: Server-side, within the `session.command` execution path. We do not prevent session creation (clients may auto-create sessions), but we can block execution if the session does not meet the required criteria. If stronger semantics are needed (e.g., "must have prior messages"), define that explicitly and check server-side. + +### Alias Resolution + +**Single source of truth**: `Command.get()` on the server resolves aliases. + +**Client behavior**: No changes. Alias resolution only applies to the `session.command` API entrypoint. Clients that already call the command endpoint will work; clients that send `/cmd` as prompt text remain unchanged. + +### Error Handling + +**Cross-client errors**: Use `Session.Event.Error` (not TUI-specific events) so all clients can handle failures. + +### Event Emission + +**Backward compatible**: Keep `command.executed` schema unchanged. Only emit when a message is created. + +--- + +## Implementation + +### Part 1: Plugin Hook Type + +Add the `plugin.command` hook to the plugin type definitions. + +**File**: `packages/plugin/src/index.ts` + +- [x] **1.1** Add `plugin.command` hook to `Hooks` interface + +```typescript +export interface Hooks { + // ... existing hooks ... + + /** + * Register custom slash commands (accessible via /command in TUI/web) + */ + "plugin.command"?: { + [name: string]: { + description: string + aliases?: string[] + sessionOnly?: boolean + execute(input: { + sessionID: string + arguments: string + client: ReturnType + }): Promise + } + } +} +``` + +--- + +### Part 2: Command Schema + +Add fields to support plugin commands, aliases, and sessionOnly. + +**File**: `packages/opencode/src/command/index.ts` + +- [x] **2.1** Update `Command.Info` schema + +```typescript +export const Info = z + .object({ + name: z.string(), + description: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), + template: z.string(), + type: z.enum(["template", "plugin"]).default("template"), + subtask: z.boolean().optional(), + sessionOnly: z.boolean().optional(), + aliases: z.array(z.string()).optional(), + }) + .meta({ + ref: "Command", + }) +``` + +- [x] **2.2** Update default commands to include `type: "template"` + +```typescript +const result: Record = { + [Default.INIT]: { + name: Default.INIT, + type: "template", + description: "create/update AGENTS.md", + template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), + }, + [Default.REVIEW]: { + name: Default.REVIEW, + type: "template", + description: "review changes [commit|branch|pr], defaults to uncommitted", + template: PROMPT_REVIEW.replace("${path}", Instance.worktree), + subtask: true, + }, +} +``` + +- [x] **2.3** Update config command loading to include `type: "template"` + +```typescript +for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { + name, + type: "template", + agent: command.agent, + // ... + } +} +``` + +--- + +### Part 3: Plugin Command Loading + +Load plugin commands into the command registry. + +**File**: `packages/opencode/src/command/index.ts` + +- [x] **3.1** Import Plugin namespace + +```typescript +import { Plugin } from "../plugin" +``` + +- [x] **3.2** Load plugin commands in `state()` + +```typescript +const state = Instance.state(async () => { + const cfg = await Config.get() + const result: Record = { + // ... default commands ... + } + + // Config commands + for (const [name, command] of Object.entries(cfg.command ?? {})) { + // ... + } + + // Plugin commands + const plugins = await Plugin.list() + for (const plugin of plugins) { + const commands = plugin["plugin.command"] + if (!commands) continue + for (const [name, cmd] of Object.entries(commands)) { + if (result[name]) continue // Don't override existing commands + result[name] = { + name, + type: "plugin", + description: cmd.description, + template: "", // Plugin commands don't use templates + sessionOnly: cmd.sessionOnly, + aliases: cmd.aliases, + } + } + } + + return result +}) +``` + +- [x] **3.3** Update `Command.get()` to resolve aliases (server-side only) + +```typescript +export async function get(name: string) { + const commands = await state() + if (commands[name]) return commands[name] + for (const cmd of Object.values(commands)) { + if (cmd.aliases?.includes(name)) return cmd + } + return undefined +} +``` + +--- + +### Part 4: Plugin Command Execution + +Handle plugin command execution in the session prompt. + +**File**: `packages/opencode/src/session/prompt.ts` + +- [x] **4.1** Add Plugin import (already present) + +```typescript +import { Plugin } from "../plugin" +``` + +- [x] **4.2** Handle plugin commands in `SessionPrompt.command()` + +```typescript +export async function command(input: CommandInput) { + log.info("command", input) + const command = await Command.get(input.command) + if (!command) { + log.warn("command not found", { command: input.command }) + return + } + + // Plugin commands execute directly via hook + if (command.type === "plugin") { + const plugins = await Plugin.list() + for (const plugin of plugins) { + const pluginCommands = plugin["plugin.command"] + const pluginCommand = pluginCommands?.[command.name] + if (!pluginCommand) continue + + try { + const client = await Plugin.client() + await pluginCommand.execute({ + sessionID: input.sessionID, + arguments: input.arguments, + client, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.error("plugin command failed", { command: command.name, error: message }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ + message: `/${command.name} failed: ${message}`, + }).toObject(), + }) + throw error + } + + // Emit event if plugin created a message + const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + if (last.length > 0) { + Bus.publish(Command.Event.Executed, { + name: command.name, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: last[0].info.id, + }) + return last[0] + } + return + } + return + } + + // Template commands (existing logic) + const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) + // ... rest of existing template execution ... +} +``` + +- [x] **4.3** Add `Plugin.client()` export + +**File**: `packages/opencode/src/plugin/index.ts` + +```typescript +export async function client() { + return state().then((x) => x.input.client) +} +``` + +- [x] **4.4** Exclude `plugin.command` from trigger + +```typescript +export async function trigger< + Name extends Exclude, "auth" | "event" | "tool" | "plugin.command">, + // ... +> +``` + +- [x] **4.5** Add type guard for plugin functions + +```typescript +for (const [_name, fn] of Object.entries(mod)) { + if (typeof fn !== "function") continue + const init = await fn(input) + hooks.push(init) +} +``` + +--- + +### Part 5: SDK Regeneration + +Regenerate SDK types after schema changes. + +- [x] **5.1** Regenerate SDK types + +```bash +cd packages/sdk/js && bun run script/build.ts +``` + +--- + +## File Reference Summary + +| Path | Changes | +|------|---------| +| `packages/plugin/src/index.ts` | Add `plugin.command` hook type | +| `packages/opencode/src/command/index.ts` | Schema updates, plugin loading, alias resolution | +| `packages/opencode/src/session/prompt.ts` | Plugin command execution (command API path) | +| `packages/opencode/src/plugin/index.ts` | Add `client()` export, type guard, exclude from trigger | +| `packages/opencode/src/config/config.ts` | Extend command config schema for aliases/sessionOnly (if needed) | + +--- + +## Implementation Order + +``` +Phase 1: Core Infrastructure + 1.1 Add plugin.command hook type + 2.1 Update Command.Info schema + 2.2 Update default commands + 2.3 Update config command loading + 3.1 Import Plugin in command/index.ts + 3.2 Load plugin commands + 3.3 Add alias resolution to Command.get() + +Phase 2: Execution Path + 4.1 Import Plugin in prompt.ts + 4.2 Handle plugin commands in SessionPrompt.command() + 4.3 Add Plugin.client() export + 4.4 Exclude plugin.command from trigger + 4.5 Add type guard for plugin functions + +Phase 3: Finalization + 5.1 Regenerate SDK types +``` + +--- + +## Testing Checklist + +### Plugin Commands +- [ ] Create `.opencode/command/hello.ts` with a simple command +- [ ] Verify `/hello` appears in autocomplete +- [ ] Verify `/hello` executes and calls the plugin hook +- [ ] Verify plugin command can use `client` to send messages + +### sessionOnly +- [ ] Create command with `sessionOnly: true` +- [ ] Verify `session.command` rejects when session does not meet the required criteria +- [ ] Verify `/cmd` works when sent via the command API for an eligible session + +### Aliases +- [ ] Create command with `aliases: ["hi"]` +- [ ] Verify alias resolves via `session.command` (server-side) +- [ ] Verify logs/events use canonical name, not alias + +### Error Handling +- [ ] Create command that throws an error +- [ ] Verify error appears in TUI +- [ ] Verify error appears in web app + +### Event Emission +- [ ] Create command that sends a message +- [ ] Verify `command.executed` event fires +- [ ] Create command that doesn't send a message +- [ ] Verify no event fires (and no error) + +--- + +## Differences from PR #4411 + +| Aspect | PR #4411 | This Plan | +|--------|----------|-----------| +| sessionOnly enforcement | Autocomplete only | Server-side in `session.command` execution path | +| Error handling | `TuiEvent.ToastShow` | `Session.Event.Error` | +| Event emission | Missing | Conditional when message created | +| Alias resolution | TUI only | Server-side for command API only | +| Client changes | TUI-only | None | + +--- + +## Estimated Scope + +- **New code**: ~70 lines +- **Modified code**: ~30 lines +- **Files touched**: 4-5 From 94ee5baa42892805f59af806f988648cdc50b329 Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:39:27 -0800 Subject: [PATCH 5/6] fix: only emit Command.Event.Executed when plugin creates new message --- packages/opencode/src/session/prompt.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 87b78a58fdd..c5d3654c051 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1315,6 +1315,9 @@ export namespace SessionPrompt { const pluginCommand = pluginCommands?.[command.name] if (!pluginCommand) continue + const messagesBefore = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + const lastMessageIDBefore = messagesBefore[0]?.info.id + try { const client = await Plugin.client() await pluginCommand.execute({ @@ -1334,16 +1337,16 @@ export namespace SessionPrompt { throw error } - // Emit event if plugin created a message - const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) - if (last.length > 0) { + // Emit event if plugin created a new message + const messagesAfter = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + if (messagesAfter.length > 0 && messagesAfter[0].info.id !== lastMessageIDBefore) { Bus.publish(Command.Event.Executed, { name: command.name, sessionID: input.sessionID, arguments: input.arguments, - messageID: last[0].info.id, + messageID: messagesAfter[0].info.id, }) - return last[0] + return messagesAfter[0] } return } From 560ff689cf780110279d562d1429652cc4297221 Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:43:54 -0800 Subject: [PATCH 6/6] removing docs --- ...plugin-commands-improvements-2025-12-26.md | 399 ------------------ 1 file changed, 399 deletions(-) delete mode 100644 CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md diff --git a/CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md b/CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md deleted file mode 100644 index 8480c13b798..00000000000 --- a/CONTEXT/PLAN-4411-plugin-commands-improvements-2025-12-26.md +++ /dev/null @@ -1,399 +0,0 @@ -# Plan: Plugin Commands Feature - -**Issue Reference:** Based on PR #4411 concepts -**Created:** 2025-12-26 -**Revised:** 2025-12-27 -**Status:** Complete - ---- - -## Overview - -Implement plugin-registered slash commands that execute via the existing `session.command` API entrypoint. Alias resolution and error/event handling are server-side only. This plan avoids any client changes and stays backward compatible with existing clients that already call the command API. - -This plan implements the feature from scratch with fixes for the issues identified in PR #4411's review, while minimizing footprint and aligning with upstream patterns. - ---- - -## Design Decisions - -### sessionOnly Enforcement - -**Definition**: A `sessionOnly` command requires an **existing** session. - -**Enforcement point**: Server-side, within the `session.command` execution path. We do not prevent session creation (clients may auto-create sessions), but we can block execution if the session does not meet the required criteria. If stronger semantics are needed (e.g., "must have prior messages"), define that explicitly and check server-side. - -### Alias Resolution - -**Single source of truth**: `Command.get()` on the server resolves aliases. - -**Client behavior**: No changes. Alias resolution only applies to the `session.command` API entrypoint. Clients that already call the command endpoint will work; clients that send `/cmd` as prompt text remain unchanged. - -### Error Handling - -**Cross-client errors**: Use `Session.Event.Error` (not TUI-specific events) so all clients can handle failures. - -### Event Emission - -**Backward compatible**: Keep `command.executed` schema unchanged. Only emit when a message is created. - ---- - -## Implementation - -### Part 1: Plugin Hook Type - -Add the `plugin.command` hook to the plugin type definitions. - -**File**: `packages/plugin/src/index.ts` - -- [x] **1.1** Add `plugin.command` hook to `Hooks` interface - -```typescript -export interface Hooks { - // ... existing hooks ... - - /** - * Register custom slash commands (accessible via /command in TUI/web) - */ - "plugin.command"?: { - [name: string]: { - description: string - aliases?: string[] - sessionOnly?: boolean - execute(input: { - sessionID: string - arguments: string - client: ReturnType - }): Promise - } - } -} -``` - ---- - -### Part 2: Command Schema - -Add fields to support plugin commands, aliases, and sessionOnly. - -**File**: `packages/opencode/src/command/index.ts` - -- [x] **2.1** Update `Command.Info` schema - -```typescript -export const Info = z - .object({ - name: z.string(), - description: z.string().optional(), - agent: z.string().optional(), - model: z.string().optional(), - template: z.string(), - type: z.enum(["template", "plugin"]).default("template"), - subtask: z.boolean().optional(), - sessionOnly: z.boolean().optional(), - aliases: z.array(z.string()).optional(), - }) - .meta({ - ref: "Command", - }) -``` - -- [x] **2.2** Update default commands to include `type: "template"` - -```typescript -const result: Record = { - [Default.INIT]: { - name: Default.INIT, - type: "template", - description: "create/update AGENTS.md", - template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), - }, - [Default.REVIEW]: { - name: Default.REVIEW, - type: "template", - description: "review changes [commit|branch|pr], defaults to uncommitted", - template: PROMPT_REVIEW.replace("${path}", Instance.worktree), - subtask: true, - }, -} -``` - -- [x] **2.3** Update config command loading to include `type: "template"` - -```typescript -for (const [name, command] of Object.entries(cfg.command ?? {})) { - result[name] = { - name, - type: "template", - agent: command.agent, - // ... - } -} -``` - ---- - -### Part 3: Plugin Command Loading - -Load plugin commands into the command registry. - -**File**: `packages/opencode/src/command/index.ts` - -- [x] **3.1** Import Plugin namespace - -```typescript -import { Plugin } from "../plugin" -``` - -- [x] **3.2** Load plugin commands in `state()` - -```typescript -const state = Instance.state(async () => { - const cfg = await Config.get() - const result: Record = { - // ... default commands ... - } - - // Config commands - for (const [name, command] of Object.entries(cfg.command ?? {})) { - // ... - } - - // Plugin commands - const plugins = await Plugin.list() - for (const plugin of plugins) { - const commands = plugin["plugin.command"] - if (!commands) continue - for (const [name, cmd] of Object.entries(commands)) { - if (result[name]) continue // Don't override existing commands - result[name] = { - name, - type: "plugin", - description: cmd.description, - template: "", // Plugin commands don't use templates - sessionOnly: cmd.sessionOnly, - aliases: cmd.aliases, - } - } - } - - return result -}) -``` - -- [x] **3.3** Update `Command.get()` to resolve aliases (server-side only) - -```typescript -export async function get(name: string) { - const commands = await state() - if (commands[name]) return commands[name] - for (const cmd of Object.values(commands)) { - if (cmd.aliases?.includes(name)) return cmd - } - return undefined -} -``` - ---- - -### Part 4: Plugin Command Execution - -Handle plugin command execution in the session prompt. - -**File**: `packages/opencode/src/session/prompt.ts` - -- [x] **4.1** Add Plugin import (already present) - -```typescript -import { Plugin } from "../plugin" -``` - -- [x] **4.2** Handle plugin commands in `SessionPrompt.command()` - -```typescript -export async function command(input: CommandInput) { - log.info("command", input) - const command = await Command.get(input.command) - if (!command) { - log.warn("command not found", { command: input.command }) - return - } - - // Plugin commands execute directly via hook - if (command.type === "plugin") { - const plugins = await Plugin.list() - for (const plugin of plugins) { - const pluginCommands = plugin["plugin.command"] - const pluginCommand = pluginCommands?.[command.name] - if (!pluginCommand) continue - - try { - const client = await Plugin.client() - await pluginCommand.execute({ - sessionID: input.sessionID, - arguments: input.arguments, - client, - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log.error("plugin command failed", { command: command.name, error: message }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ - message: `/${command.name} failed: ${message}`, - }).toObject(), - }) - throw error - } - - // Emit event if plugin created a message - const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) - if (last.length > 0) { - Bus.publish(Command.Event.Executed, { - name: command.name, - sessionID: input.sessionID, - arguments: input.arguments, - messageID: last[0].info.id, - }) - return last[0] - } - return - } - return - } - - // Template commands (existing logic) - const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) - // ... rest of existing template execution ... -} -``` - -- [x] **4.3** Add `Plugin.client()` export - -**File**: `packages/opencode/src/plugin/index.ts` - -```typescript -export async function client() { - return state().then((x) => x.input.client) -} -``` - -- [x] **4.4** Exclude `plugin.command` from trigger - -```typescript -export async function trigger< - Name extends Exclude, "auth" | "event" | "tool" | "plugin.command">, - // ... -> -``` - -- [x] **4.5** Add type guard for plugin functions - -```typescript -for (const [_name, fn] of Object.entries(mod)) { - if (typeof fn !== "function") continue - const init = await fn(input) - hooks.push(init) -} -``` - ---- - -### Part 5: SDK Regeneration - -Regenerate SDK types after schema changes. - -- [x] **5.1** Regenerate SDK types - -```bash -cd packages/sdk/js && bun run script/build.ts -``` - ---- - -## File Reference Summary - -| Path | Changes | -|------|---------| -| `packages/plugin/src/index.ts` | Add `plugin.command` hook type | -| `packages/opencode/src/command/index.ts` | Schema updates, plugin loading, alias resolution | -| `packages/opencode/src/session/prompt.ts` | Plugin command execution (command API path) | -| `packages/opencode/src/plugin/index.ts` | Add `client()` export, type guard, exclude from trigger | -| `packages/opencode/src/config/config.ts` | Extend command config schema for aliases/sessionOnly (if needed) | - ---- - -## Implementation Order - -``` -Phase 1: Core Infrastructure - 1.1 Add plugin.command hook type - 2.1 Update Command.Info schema - 2.2 Update default commands - 2.3 Update config command loading - 3.1 Import Plugin in command/index.ts - 3.2 Load plugin commands - 3.3 Add alias resolution to Command.get() - -Phase 2: Execution Path - 4.1 Import Plugin in prompt.ts - 4.2 Handle plugin commands in SessionPrompt.command() - 4.3 Add Plugin.client() export - 4.4 Exclude plugin.command from trigger - 4.5 Add type guard for plugin functions - -Phase 3: Finalization - 5.1 Regenerate SDK types -``` - ---- - -## Testing Checklist - -### Plugin Commands -- [ ] Create `.opencode/command/hello.ts` with a simple command -- [ ] Verify `/hello` appears in autocomplete -- [ ] Verify `/hello` executes and calls the plugin hook -- [ ] Verify plugin command can use `client` to send messages - -### sessionOnly -- [ ] Create command with `sessionOnly: true` -- [ ] Verify `session.command` rejects when session does not meet the required criteria -- [ ] Verify `/cmd` works when sent via the command API for an eligible session - -### Aliases -- [ ] Create command with `aliases: ["hi"]` -- [ ] Verify alias resolves via `session.command` (server-side) -- [ ] Verify logs/events use canonical name, not alias - -### Error Handling -- [ ] Create command that throws an error -- [ ] Verify error appears in TUI -- [ ] Verify error appears in web app - -### Event Emission -- [ ] Create command that sends a message -- [ ] Verify `command.executed` event fires -- [ ] Create command that doesn't send a message -- [ ] Verify no event fires (and no error) - ---- - -## Differences from PR #4411 - -| Aspect | PR #4411 | This Plan | -|--------|----------|-----------| -| sessionOnly enforcement | Autocomplete only | Server-side in `session.command` execution path | -| Error handling | `TuiEvent.ToastShow` | `Session.Event.Error` | -| Event emission | Missing | Conditional when message created | -| Alias resolution | TUI only | Server-side for command API only | -| Client changes | TUI-only | None | - ---- - -## Estimated Scope - -- **New code**: ~70 lines -- **Modified code**: ~30 lines -- **Files touched**: 4-5