index.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { BusEvent } from "@/bus/bus-event"
  2. import { InstanceState } from "@/effect/instance-state"
  3. import { EffectBridge } from "@/effect/bridge"
  4. import type { InstanceContext } from "@/project/instance"
  5. import { SessionID, MessageID } from "@/session/schema"
  6. import { Effect, Layer, Context, Schema } from "effect"
  7. import z from "zod"
  8. import { zod, ZodOverride } from "@/util/effect-zod"
  9. import { withStatics } from "@/util/schema"
  10. import { Config } from "@/config/config"
  11. import { MCP } from "../mcp"
  12. import { Skill } from "../skill"
  13. import PROMPT_INITIALIZE from "./template/initialize.txt"
  14. import PROMPT_REVIEW from "./template/review.txt"
  15. type State = {
  16. commands: Record<string, Info>
  17. }
  18. export const Event = {
  19. Executed: BusEvent.define(
  20. "command.executed",
  21. Schema.Struct({
  22. name: Schema.String,
  23. sessionID: SessionID,
  24. arguments: Schema.String,
  25. messageID: MessageID,
  26. }),
  27. ),
  28. }
  29. export const Info = Schema.Struct({
  30. name: Schema.String,
  31. description: Schema.optional(Schema.String),
  32. agent: Schema.optional(Schema.String),
  33. model: Schema.optional(Schema.String),
  34. source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
  35. // Some command templates are lazy promises from MCP prompt resolution.
  36. template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
  37. subtask: Schema.optional(Schema.Boolean),
  38. hints: Schema.Array(Schema.String),
  39. })
  40. .annotate({ identifier: "Command" })
  41. .pipe(withStatics((s) => ({ zod: zod(s) })))
  42. // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
  43. export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
  44. export function hints(template: string) {
  45. const result: string[] = []
  46. const numbered = template.match(/\$\d+/g)
  47. if (numbered) {
  48. for (const match of [...new Set(numbered)].sort()) result.push(match)
  49. }
  50. if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
  51. return result
  52. }
  53. export const Default = {
  54. INIT: "init",
  55. REVIEW: "review",
  56. } as const
  57. export interface Interface {
  58. readonly get: (name: string) => Effect.Effect<Info | undefined>
  59. readonly list: () => Effect.Effect<Info[]>
  60. }
  61. export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
  62. export const layer = Layer.effect(
  63. Service,
  64. Effect.gen(function* () {
  65. const config = yield* Config.Service
  66. const mcp = yield* MCP.Service
  67. const skill = yield* Skill.Service
  68. const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
  69. const cfg = yield* config.get()
  70. const bridge = yield* EffectBridge.make()
  71. const commands: Record<string, Info> = {}
  72. commands[Default.INIT] = {
  73. name: Default.INIT,
  74. description: "guided AGENTS.md setup",
  75. source: "command",
  76. get template() {
  77. return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
  78. },
  79. hints: hints(PROMPT_INITIALIZE),
  80. }
  81. commands[Default.REVIEW] = {
  82. name: Default.REVIEW,
  83. description: "review changes [commit|branch|pr], defaults to uncommitted",
  84. source: "command",
  85. get template() {
  86. return PROMPT_REVIEW.replace("${path}", ctx.worktree)
  87. },
  88. hints: hints(PROMPT_REVIEW),
  89. }
  90. for (const [name, command] of Object.entries(cfg.command ?? {})) {
  91. commands[name] = {
  92. name,
  93. agent: command.agent,
  94. model: command.model,
  95. description: command.description,
  96. source: "command",
  97. get template() {
  98. return command.template
  99. },
  100. subtask: command.subtask,
  101. hints: hints(command.template),
  102. }
  103. }
  104. for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
  105. commands[name] = {
  106. name,
  107. source: "mcp",
  108. description: prompt.description,
  109. get template() {
  110. return bridge.promise(
  111. mcp
  112. .getPrompt(
  113. prompt.client,
  114. prompt.name,
  115. prompt.arguments
  116. ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
  117. : {},
  118. )
  119. .pipe(
  120. Effect.map(
  121. (template) =>
  122. template?.messages
  123. .map((message) => (message.content.type === "text" ? message.content.text : ""))
  124. .join("\n") || "",
  125. ),
  126. ),
  127. )
  128. },
  129. hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
  130. }
  131. }
  132. for (const item of yield* skill.all()) {
  133. if (commands[item.name]) continue
  134. commands[item.name] = {
  135. name: item.name,
  136. description: item.description,
  137. source: "skill",
  138. get template() {
  139. return item.content
  140. },
  141. hints: [],
  142. }
  143. }
  144. return {
  145. commands,
  146. }
  147. })
  148. const state = yield* InstanceState.make<State>((ctx) => init(ctx))
  149. const get = Effect.fn("Command.get")(function* (name: string) {
  150. const s = yield* InstanceState.get(state)
  151. return s.commands[name]
  152. })
  153. const list = Effect.fn("Command.list")(function* () {
  154. const s = yield* InstanceState.get(state)
  155. return Object.values(s.commands)
  156. })
  157. return Service.of({ get, list })
  158. }),
  159. )
  160. export const defaultLayer = layer.pipe(
  161. Layer.provide(Config.defaultLayer),
  162. Layer.provide(MCP.defaultLayer),
  163. Layer.provide(Skill.defaultLayer),
  164. )
  165. export * as Command from "."