diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 7be3274871..461e483375 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -21,6 +21,16 @@ export type Evaluation = { export type Data = { commands: Map> + handlers: Map +} + +export type Handler = (input: { + readonly sessionID: string + readonly arguments: string +}) => Effect.Effect + +export type Definition = Omit & { + readonly execute: Handler } export class NotFoundError extends Schema.TaggedError()("Command.NotFoundError", { @@ -36,6 +46,7 @@ export class EvaluationError extends Schema.TaggedError()("Comm export type Draft = { list: () => readonly Info[] get: (name: string) => Info | undefined + add: (definition: Definition) => void update: (name: string, update: (command: Types.DeepMutable) => void) => void remove: (name: string) => void } @@ -46,6 +57,7 @@ export interface Interface extends State.Transformable { readonly evaluate: (input: { readonly name: string readonly arguments?: string + readonly sessionID?: string }) => Effect.Effect } @@ -62,10 +74,21 @@ const layer = () => const shell = yield* ShellSelect.Service const state = State.create({ name: "command", - initial: () => ({ commands: new Map() }), + initial: () => ({ commands: new Map(), handlers: new Map() }), draft: (draft) => ({ list: () => Array.from(draft.commands.values()) as Info[], get: (name) => draft.commands.get(name), + add: (definition) => { + draft.commands.set(definition.name, { + name: definition.name, + template: "", + description: definition.description, + agent: definition.agent, + model: definition.model, + subtask: definition.subtask, + }) + draft.handlers.set(definition.name, definition.execute) + }, update: (name, update) => { const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable) if (!draft.commands.has(name)) draft.commands.set(name, current) @@ -74,6 +97,7 @@ const layer = () => }, remove: (name) => { draft.commands.delete(name) + draft.handlers.delete(name) }, }), finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid), @@ -104,6 +128,24 @@ const layer = () => }), evaluate: Effect.fn("Command.evaluate")(function* (input) { const command = staticCommand(input.name) + const handler = state.get().handlers.get(input.name) + if (handler) { + if (input.sessionID === undefined) + return yield* new EvaluationError({ + command: input.name, + message: `Command requires a session: ${input.name}`, + }) + const text = yield* handler({ sessionID: input.sessionID, arguments: input.arguments ?? "" }).pipe( + Effect.mapError( + (error) => + new EvaluationError({ + command: input.name, + message: error instanceof Error ? error.message : String(error), + }), + ), + ) + return { text } + } if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", { location, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4721af5c8c..9094348841 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -661,7 +661,11 @@ const layer = Layer.effect( command: input.command, message: `Command not found: ${input.command}`, }) - const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments }) + const evaluated = yield* commands.evaluate({ + name: input.command, + arguments: input.arguments, + sessionID: input.sessionID, + }) // TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands. const agent = command.agent ?? input.agent diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index b6277627b8..9686f438d4 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -74,4 +74,30 @@ describe("Command", () => { expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output") }), ) + + it.effect("executes registered command handlers", () => + Effect.gen(function* () { + const command = yield* Command.Service + const calls: string[] = [] + yield* command.transform((editor) => { + editor.add({ + name: "deploy", + description: "Prepare a deployment", + execute: ({ sessionID, arguments: input }) => + Effect.sync(() => { + calls.push(`${sessionID}:${input}`) + return `Deployment prepared for ${input}` + }), + }) + }) + + expect(yield* command.get("deploy")).toEqual( + Command.Info.make({ name: "deploy", template: "", description: "Prepare a deployment" }), + ) + expect(yield* command.evaluate({ name: "deploy", sessionID: "session-1", arguments: "staging" })).toEqual({ + text: "Deployment prepared for staging", + }) + expect(calls).toEqual(["session-1:staging"]) + }), + ) }) diff --git a/packages/plugin/src/effect/command.ts b/packages/plugin/src/effect/command.ts index 59be027df9..812fd4ebb3 100644 --- a/packages/plugin/src/effect/command.ts +++ b/packages/plugin/src/effect/command.ts @@ -3,9 +3,17 @@ import type { CommandInfo } from "@opencode-ai/client" import type { Effect } from "effect" import type { Transform } from "./registration.js" +export interface CommandDefinition extends Omit { + readonly execute: (input: { + readonly sessionID: string + readonly arguments: string + }) => Effect.Effect +} + export interface CommandDraft { list(): readonly CommandInfo[] get(name: string): CommandInfo | undefined + add(definition: CommandDefinition): void update(name: string, update: (command: CommandInfo) => void): void remove(name: string): void } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index a045920503..160f3ef135 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -149,7 +149,22 @@ export function fromPromise(plugin: Plugin) { }, command: { list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list), - transform: transform(host.command), + transform: (callback) => + register( + host.command.transform((draft) => + callback({ + list: draft.list, + get: draft.get, + add: (definition) => + draft.add({ + ...definition, + execute: (input) => Effect.promise(() => Promise.resolve(definition.execute(input))), + }), + update: draft.update, + remove: draft.remove, + }), + ), + ), reload: () => run(host.command.reload()), }, event: { diff --git a/packages/plugin/src/promise/command.ts b/packages/plugin/src/promise/command.ts index cc9de3f3cb..4f52f128fe 100644 --- a/packages/plugin/src/promise/command.ts +++ b/packages/plugin/src/promise/command.ts @@ -2,9 +2,14 @@ import type { CommandApi } from "@opencode-ai/client/promise/api" import type { CommandInfo } from "@opencode-ai/client" import type { Transform } from "./registration.js" +export interface CommandDefinition extends Omit { + readonly execute: (input: { readonly sessionID: string; readonly arguments: string }) => string | Promise +} + export interface CommandDraft { list(): readonly CommandInfo[] get(name: string): CommandInfo | undefined + add(definition: CommandDefinition): void update(name: string, update: (command: CommandInfo) => void): void remove(name: string): void }