diff --git a/packages/opencode/src/command/command.ts b/packages/opencode/src/command/command.ts new file mode 100644 index 0000000000..fe9005edb2 --- /dev/null +++ b/packages/opencode/src/command/command.ts @@ -0,0 +1,186 @@ +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" +import { SessionID, MessageID } from "@/session/schema" +import { Effect, Layer, Context } from "effect" +import z from "zod" +import { Config } from "../config" +import { MCP } from "../mcp" +import { Skill } from "../skill" +import PROMPT_INITIALIZE from "./template/initialize.txt" +import PROMPT_REVIEW from "./template/review.txt" + +type State = { + commands: Record +} + +export const Event = { + Executed: BusEvent.define( + "command.executed", + z.object({ + name: z.string(), + sessionID: SessionID.zod, + arguments: z.string(), + messageID: MessageID.zod, + }), + ), +} + +export const Info = z + .object({ + name: z.string(), + description: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), + source: z.enum(["command", "mcp", "skill"]).optional(), + // workaround for zod not supporting async functions natively so we use getters + // https://zod.dev/v4/changelog?id=zfunction + template: z.promise(z.string()).or(z.string()), + subtask: z.boolean().optional(), + hints: z.array(z.string()), + }) + .meta({ + ref: "Command", + }) + +// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it +export type Info = Omit, "template"> & { template: Promise | string } + +export function hints(template: string) { + const result: string[] = [] + const numbered = template.match(/\$\d+/g) + if (numbered) { + for (const match of [...new Set(numbered)].sort()) result.push(match) + } + if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") + return result +} + +export const Default = { + INIT: "init", + REVIEW: "review", +} as const + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Command") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const mcp = yield* MCP.Service + const skill = yield* Skill.Service + + const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { + const cfg = yield* config.get() + const bridge = yield* EffectBridge.make() + const commands: Record = {} + + commands[Default.INIT] = { + name: Default.INIT, + description: "guided AGENTS.md setup", + source: "command", + get template() { + return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) + }, + hints: hints(PROMPT_INITIALIZE), + } + commands[Default.REVIEW] = { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", + get template() { + return PROMPT_REVIEW.replace("${path}", ctx.worktree) + }, + subtask: true, + hints: hints(PROMPT_REVIEW), + } + + for (const [name, command] of Object.entries(cfg.command ?? {})) { + commands[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + source: "command", + get template() { + return command.template + }, + subtask: command.subtask, + hints: hints(command.template), + } + } + + for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { + commands[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return bridge.promise( + mcp + .getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ) + .pipe( + Effect.map( + (template) => + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ), + ), + ) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + + for (const item of yield* skill.all()) { + if (commands[item.name]) continue + commands[item.name] = { + name: item.name, + description: item.description, + source: "skill", + get template() { + return item.content + }, + hints: [], + } + } + + return { + commands, + } + }) + + const state = yield* InstanceState.make((ctx) => init(ctx)) + + const get = Effect.fn("Command.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.commands[name] + }) + + const list = Effect.fn("Command.list")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.commands) + }) + + return Service.of({ get, list }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(Skill.defaultLayer), +) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 539ae0dac6..2e530360c5 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,188 +1 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" -import { EffectBridge } from "@/effect/bridge" -import type { InstanceContext } from "@/project/instance" -import { SessionID, MessageID } from "@/session/schema" -import { Effect, Layer, Context } from "effect" -import z from "zod" -import { Config } from "../config" -import { MCP } from "../mcp" -import { Skill } from "../skill" -import PROMPT_INITIALIZE from "./template/initialize.txt" -import PROMPT_REVIEW from "./template/review.txt" - -export namespace Command { - type State = { - commands: Record - } - - export const Event = { - Executed: BusEvent.define( - "command.executed", - z.object({ - name: z.string(), - sessionID: SessionID.zod, - arguments: z.string(), - messageID: MessageID.zod, - }), - ), - } - - export const Info = z - .object({ - name: z.string(), - description: z.string().optional(), - agent: z.string().optional(), - model: z.string().optional(), - source: z.enum(["command", "mcp", "skill"]).optional(), - // workaround for zod not supporting async functions natively so we use getters - // https://zod.dev/v4/changelog?id=zfunction - template: z.promise(z.string()).or(z.string()), - subtask: z.boolean().optional(), - hints: z.array(z.string()), - }) - .meta({ - ref: "Command", - }) - - // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it - export type Info = Omit, "template"> & { template: Promise | string } - - export function hints(template: string) { - const result: string[] = [] - const numbered = template.match(/\$\d+/g) - if (numbered) { - for (const match of [...new Set(numbered)].sort()) result.push(match) - } - if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") - return result - } - - export const Default = { - INIT: "init", - REVIEW: "review", - } as const - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly list: () => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Command") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const mcp = yield* MCP.Service - const skill = yield* Skill.Service - - const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { - const cfg = yield* config.get() - const bridge = yield* EffectBridge.make() - const commands: Record = {} - - commands[Default.INIT] = { - name: Default.INIT, - description: "guided AGENTS.md setup", - source: "command", - get template() { - return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) - }, - hints: hints(PROMPT_INITIALIZE), - } - commands[Default.REVIEW] = { - name: Default.REVIEW, - description: "review changes [commit|branch|pr], defaults to uncommitted", - source: "command", - get template() { - return PROMPT_REVIEW.replace("${path}", ctx.worktree) - }, - subtask: true, - hints: hints(PROMPT_REVIEW), - } - - for (const [name, command] of Object.entries(cfg.command ?? {})) { - commands[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, - source: "command", - get template() { - return command.template - }, - subtask: command.subtask, - hints: hints(command.template), - } - } - - for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { - commands[name] = { - name, - source: "mcp", - description: prompt.description, - get template() { - return bridge.promise( - mcp - .getPrompt( - prompt.client, - prompt.name, - prompt.arguments - ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) - : {}, - ) - .pipe( - Effect.map( - (template) => - template?.messages - .map((message) => (message.content.type === "text" ? message.content.text : "")) - .join("\n") || "", - ), - ), - ) - }, - hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], - } - } - - for (const item of yield* skill.all()) { - if (commands[item.name]) continue - commands[item.name] = { - name: item.name, - description: item.description, - source: "skill", - get template() { - return item.content - }, - hints: [], - } - } - - return { - commands, - } - }) - - const state = yield* InstanceState.make((ctx) => init(ctx)) - - const get = Effect.fn("Command.get")(function* (name: string) { - const s = yield* InstanceState.get(state) - return s.commands[name] - }) - - const list = Effect.fn("Command.list")(function* () { - const s = yield* InstanceState.get(state) - return Object.values(s.commands) - }) - - return Service.of({ get, list }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Skill.defaultLayer), - ) -} +export * as Command from "./command"