Compare commits

..

1 Commits

Author SHA1 Message Date
Brendonovich 0c5d4d8ca6 feat(plugin): add executable slash commands 2026-08-23 16:27:46 +00:00
23 changed files with 110 additions and 253 deletions
@@ -9,7 +9,7 @@ import { type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = true
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,18 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
// TODO: Restore project edits when the V2 client exposes a project update API.
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
throw new Error(`Project ${props.project.id} cannot be updated`)
}
serverCtx().sync.project.meta(props.project.worktree, {
-10
View File
@@ -1291,15 +1291,6 @@ export interface CredentialApi<E = never> {
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
}
export type ProjectUpdateOutput = Project.Info
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
export type ProjectCurrentInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1308,7 +1299,6 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
@@ -139,8 +139,6 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -921,14 +919,6 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
preserveEffect<ProjectCurrentOutput>()(
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -936,7 +926,6 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
@@ -133,8 +133,6 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -1253,18 +1251,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
@@ -2269,14 +2269,6 @@ export type McpServerNotFoundError = {
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type ProjectNotFoundError = {
readonly _tag: "ProjectNotFoundError"
readonly projectID: string
readonly message: string
}
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -4387,27 +4379,6 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly name?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["commands"]
}
export type ProjectUpdateOutput = Project
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+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,
+1 -30
View File
@@ -29,13 +29,6 @@ export type Current = ProjectSchema.Current
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
projectID: ID,
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -55,7 +48,6 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -153,27 +145,6 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const row = yield* db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_url_override: input.icon?.override,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
const result = fromRow(row)
yield* bus.publish(ProjectSchema.Event.Updated, result)
return result
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -287,7 +258,7 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, resolve, update })
return Service.of({ list, resolve })
}),
)
-5
View File
@@ -13,11 +13,6 @@ export type Current = typeof Current.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export const Event = Project.Event
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
+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
+1 -39
View File
@@ -18,8 +18,6 @@ import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
export { DirectoryUnavailableError } from "./worktree/directory.js"
@@ -149,7 +147,6 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const proc = yield* AppProcess.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
@@ -263,41 +260,6 @@ const layer = Layer.effect(
strategy: input.strategy,
}),
)
const project = yield* db
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const script = project?.commands?.start?.trim()
if (script) {
yield* proc
.run(
ChildProcess.make(
process.platform === "win32" ? "cmd" : "bash",
[process.platform === "win32" ? "/c" : "-lc", script],
{
cwd: result.directory,
extendEnv: true,
stdin: "ignore",
},
),
)
.pipe(
Effect.flatMap((output) =>
output.exitCode === 0
? Effect.void
: Effect.logError("worktree setup script failed", {
directory: result.directory,
exitCode: output.exitCode,
stderr: output.stderr.toString("utf8"),
}),
),
Effect.catchCause((cause) =>
Effect.logError("worktree setup script failed", { directory: result.directory, cause }),
),
)
}
return result
})
@@ -380,7 +342,7 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
+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"])
}),
)
})
@@ -79,7 +79,6 @@ describe("node build", () => {
return Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
update: () => Effect.die("unused"),
})
}),
)
-1
View File
@@ -6,6 +6,5 @@ export const globalProjectLayer = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
update: () => Effect.die("unused"),
}),
)
-1
View File
@@ -13,7 +13,6 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("unused"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
-21
View File
@@ -66,27 +66,6 @@ describe("Project.list", () => {
)
})
describe("Project.update", () => {
it.effect("updates project metadata", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* Project.Service
const id = Project.ID.make("updated")
yield* db
.insert(ProjectTable)
.values({ id, worktree: abs("/updated"), sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
const result = yield* project.update({ projectID: id, name: "Updated", commands: { start: "bun install" } })
expect(result.name).toBe("Updated")
expect(result.commands).toEqual({ start: "bun install" })
expect(result.time.updated).toBeGreaterThan(1)
}),
)
})
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
-28
View File
@@ -192,34 +192,6 @@ describe("Worktree", () => {
}),
)
it.live("runs the project setup script in a new worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-script"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* input.db
.update(ProjectTable)
.set({ commands: { start: "echo ready > setup.txt" } })
.where(eq(ProjectTable.id, input.projectID))
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
})
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.txt")).text())).toContain("ready")
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
}),
)
it.live("rejects a missing source directory", () =>
Effect.gen(function* () {
const input = yield* setup()
+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
}
-9
View File
@@ -62,15 +62,6 @@ export class ProviderNotFoundError extends Schema.TaggedError<ProviderNotFoundEr
{ httpApiStatus: 404 },
) {}
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
"ProjectNotFoundError",
{
projectID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class AgentNotFoundError extends Schema.TaggedError<AgentNotFoundError>()(
"AgentNotFoundError",
{
+1 -17
View File
@@ -1,11 +1,9 @@
import { Project } from "@opencode-ai/schema/project"
import { Schema, Struct } from "effect"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ProjectNotFoundError } from "../errors.js"
const root = "/api/project"
const UpdatePayload = Schema.Struct(Struct.omit(Project.UpdateInput.fields, ["projectID"]))
export const ProjectGroup = HttpApiGroup.make("server.project")
.add(
@@ -19,20 +17,6 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
.add(
HttpApiEndpoint.patch("project.update", `${root}/:projectID`, {
params: { projectID: Project.ID },
payload: UpdatePayload,
success: Project.Info,
error: ProjectNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.project.update",
summary: "Update project",
description: "Update project metadata and workspace setup commands.",
}),
),
)
.add(
HttpApiEndpoint.get("project.current", `${root}/current`, {
query: LocationQuery,
-8
View File
@@ -46,13 +46,5 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "Project" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Schema.Struct({
projectID: ID,
name: optional(Schema.String),
icon: optional(Icon),
commands: optional(Commands),
}).annotate({ identifier: "Project.UpdateInput" })
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
const Updated = ephemeral({ type: "project.updated", schema: Info.fields })
export const Event = { Updated, Definitions: inventory(Updated) }
-13
View File
@@ -3,23 +3,10 @@ import { Project } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProjectNotFoundError } from "@opencode-ai/protocol/errors"
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
handlers
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.update", (ctx) =>
Project.Service.use((project) =>
project
.update({ ...ctx.payload, projectID: ctx.params.projectID })
.pipe(
Effect.catchTag(
"Project.NotFoundError",
(error) => new ProjectNotFoundError({ projectID: error.projectID, message: "Project not found" }),
),
),
),
)
.handle("project.current", () =>
Location.Service.use((location) =>
Effect.succeed({