Compare commits

..

1 Commits

Author SHA1 Message Date
Brendonovich 0c5d4d8ca6 feat(plugin): add executable slash commands 2026-08-23 16:27:46 +00:00
8 changed files with 118 additions and 50 deletions
@@ -4,10 +4,7 @@ import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
import { Resource } from "@opencode-ai/console-resource"
import z from "zod"
const Body = z.union([
z.object({ workspaceID: z.string().startsWith("wrk_") }),
z.object({ email: z.email().transform((email) => email.trim().toLowerCase()) }),
])
const Body = z.object({ workspaceID: z.string().startsWith("wrk_") })
export async function POST(event: APIEvent) {
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
@@ -18,7 +15,7 @@ export async function POST(event: APIEvent) {
if (!body.success) {
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
}
return Workspace.unblock(body.data)
.then((workspaceIDs) => Response.json({ success: true, message: "Workspace unblocked", workspaceIDs }))
return Workspace.unblock(body.data.workspaceID)
.then(() => Response.json({ success: true, message: "Workspace unblocked" }))
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
}
+12 -41
View File
@@ -7,9 +7,8 @@ import { UserTable } from "./schema/user.sql"
import { BillingTable } from "./schema/billing.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { AccountTable } from "./schema/account.sql"
import { AuthTable } from "./schema/auth.sql"
import { Key } from "./key"
import { and, eq, inArray, isNull, sql } from "drizzle-orm"
import { and, eq, isNull, sql } from "drizzle-orm"
export namespace Workspace {
export const Region = z.enum(["us", "eu", "sg", "cn"])
@@ -105,43 +104,15 @@ export namespace Workspace {
)
})
export const unblock = fn(
z.union([
z.object({ workspaceID: z.string().startsWith("wrk_") }),
z.object({ email: z.email() }),
]),
async (input) => {
return Database.transaction(async (tx) => {
const workspaces = "workspaceID" in input
? await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.id, input.workspaceID), isNull(WorkspaceTable.timeDeleted)))
: await tx
.selectDistinct({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.innerJoin(
UserTable,
and(eq(UserTable.workspaceID, WorkspaceTable.id), isNull(UserTable.timeDeleted)),
)
.innerJoin(
AuthTable,
and(
eq(AuthTable.accountID, UserTable.accountID),
eq(AuthTable.provider, "email"),
eq(AuthTable.subject, input.email.toLowerCase()),
isNull(AuthTable.timeDeleted),
),
)
.where(isNull(WorkspaceTable.timeDeleted))
if (workspaces.length === 0) throw new Error("Workspace not found")
if (!("workspaceID" in input) && workspaces.length > 1) {
throw new Error("Email is associated with multiple workspaces; use workspaceID")
}
const workspaceIDs = workspaces.map((workspace) => workspace.id)
await tx.update(WorkspaceTable).set({ is_blocked: false }).where(inArray(WorkspaceTable.id, workspaceIDs))
return workspaceIDs
})
},
)
export const unblock = fn(z.string().startsWith("wrk_"), async (workspaceID) => {
await Database.transaction(async (tx) => {
const workspace = await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0])
if (!workspace) throw new Error("Workspace not found")
await tx.update(WorkspaceTable).set({ is_blocked: false }).where(eq(WorkspaceTable.id, workspaceID))
})
})
}
+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
}