index.ts 5.5 KB

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