Compare commits

...
Author SHA1 Message Date
Brendonovich 0c5d4d8ca6 feat(plugin): add executable slash commands 2026-08-23 16:27:46 +00:00
6 changed files with 103 additions and 3 deletions
+43 -1
View File
@@ -21,6 +21,16 @@ export type Evaluation = {
export type Data = {
commands: Map<string, Types.DeepMutable<Info>>
handlers: Map<string, Handler>
}
export type Handler = (input: {
readonly sessionID: string
readonly arguments: string
}) => Effect.Effect<string, unknown>
export type Definition = Omit<Info, "template"> & {
readonly execute: Handler
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
@@ -36,6 +46,7 @@ export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Comm
export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
add: (definition: Definition) => void
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void
}
@@ -46,6 +57,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly evaluate: (input: {
readonly name: string
readonly arguments?: string
readonly sessionID?: string
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
}
@@ -62,10 +74,21 @@ const layer = () =>
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
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<Info>)
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,
+5 -1
View File
@@ -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
+26
View File
@@ -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"])
}),
)
})
+8
View File
@@ -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<CommandInfo, "template"> {
readonly execute: (input: {
readonly sessionID: string
readonly arguments: string
}) => Effect.Effect<string, unknown>
}
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
}
+16 -1
View File
@@ -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: {
+5
View File
@@ -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<CommandInfo, "template"> {
readonly execute: (input: { readonly sessionID: string; readonly arguments: string }) => string | Promise<string>
}
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
}