feat(plugin): add executable slash commands
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user