Files
opencode/packages/core/src/plugin/command.ts
T
2026-08-19 20:02:28 -04:00

117 lines
4.0 KiB
TypeScript

export * as CommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(McpEvent.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
name: "init",
description: "guided AGENTS.md setup",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
})
draft.add({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
})
for (const prompt of loaded.prompts) {
draft.add({
name: mcpCommandName(prompt.server, prompt.name),
description: prompt.description,
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.prompt.text)[index] ?? "",
]),
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
})
}
})
}),
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const quoteTrimRegex = /^["']|["']$/g