Explorar el Código

feat(core): add command registry

Dax Raad hace 2 meses
padre
commit
ec26b82b6b

+ 68 - 0
packages/core/src/command.ts

@@ -0,0 +1,68 @@
+export * as CommandV2 from "./command"
+
+import { Context, Effect, Layer, Schema } from "effect"
+import { castDraft, type Draft } from "immer"
+import { ModelV2 } from "./model"
+import { State } from "./state"
+
+export class Info extends Schema.Class<Info>("CommandV2.Info")({
+  name: Schema.String,
+  template: Schema.String,
+  description: Schema.String.pipe(Schema.optional),
+  agent: Schema.String.pipe(Schema.optional),
+  model: ModelV2.Ref.pipe(Schema.optional),
+  subtask: Schema.Boolean.pipe(Schema.optional),
+}) {}
+
+export type Data = {
+  commands: Map<string, Info>
+}
+
+export type Editor = {
+  list: () => readonly Info[]
+  get: (name: string) => Info | undefined
+  update: (name: string, update: (command: Draft<Info>) => void) => void
+  remove: (name: string) => void
+}
+
+export interface Interface {
+  readonly transform: State.Interface<Data, Editor>["transform"]
+  readonly get: (name: string) => Effect.Effect<Info | undefined>
+  readonly list: () => Effect.Effect<Info[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.sync(() => {
+    const state = State.create<Data, Editor>({
+      initial: () => ({ commands: new Map() }),
+      editor: (draft) => ({
+        list: () => Array.from(draft.commands.values()) as Info[],
+        get: (name) => draft.commands.get(name),
+        update: (name, update) => {
+          const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
+          if (!draft.commands.has(name)) draft.commands.set(name, current)
+          update(current)
+          current.name = name
+        },
+        remove: (name) => {
+          draft.commands.delete(name)
+        },
+      }),
+    })
+
+    return Service.of({
+      transform: state.transform,
+      get: Effect.fn("CommandV2.get")(function* (name) {
+        return state.get().commands.get(name)
+      }),
+      list: Effect.fn("CommandV2.list")(function* () {
+        return Array.from(state.get().commands.values())
+      }),
+    })
+  }),
+)
+
+export const locationLayer = layer

+ 4 - 0
packages/core/src/config.ts

@@ -12,6 +12,7 @@ import { AbsolutePath } from "./schema"
 import { ConfigAgent } from "./config/agent"
 import { ConfigAttachments } from "./config/attachments"
 import { ConfigCompaction } from "./config/compaction"
+import { ConfigCommand } from "./config/command"
 import { ConfigExperimental } from "./config/experimental"
 import { ConfigFormatter } from "./config/formatter"
 import { ConfigLSP } from "./config/lsp"
@@ -85,6 +86,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
   skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
     description: "Additional paths or URLs to discover skills from",
   }),
+  commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
+    description: "Named slash command definitions",
+  }),
   instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
     description: "Additional paths or URLs supplying ambient instructions",
   }),

+ 12 - 0
packages/core/src/config/command.ts

@@ -0,0 +1,12 @@
+export * as ConfigCommand from "./command"
+
+import { Schema } from "effect"
+
+export class Info extends Schema.Class<Info>("ConfigV2.Command")({
+  template: Schema.String,
+  description: Schema.String.pipe(Schema.optional),
+  agent: Schema.String.pipe(Schema.optional),
+  model: Schema.String.pipe(Schema.optional),
+  variant: Schema.String.pipe(Schema.optional),
+  subtask: Schema.Boolean.pipe(Schema.optional),
+}) {}

+ 82 - 0
packages/core/src/config/plugin/command.ts

@@ -0,0 +1,82 @@
+export * as ConfigCommandPlugin from "./command"
+
+import path from "path"
+import { Effect, Option, Schema } from "effect"
+import { CommandV2 } from "../../command"
+import { Config } from "../../config"
+import { FSUtil } from "../../fs-util"
+import { ModelV2 } from "../../model"
+import { PluginV2 } from "../../plugin"
+import { ConfigCommand } from "../command"
+import { ConfigMarkdown } from "../markdown"
+
+const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
+
+export const Plugin = PluginV2.define({
+  id: PluginV2.ID.make("config-command"),
+  effect: Effect.gen(function* () {
+    const command = yield* CommandV2.Service
+    const config = yield* Config.Service
+    const fs = yield* FSUtil.Service
+    const transform = yield* command.transform()
+    const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
+      if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
+      return loadDirectory(fs, entry.path).pipe(
+        Effect.map((commands) => [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]),
+      )
+    }).pipe(Effect.map((documents) => documents.flat()))
+
+    yield* transform((editor) => {
+      for (const document of documents) {
+        for (const [name, command] of Object.entries(document.commands ?? {})) {
+          editor.update(name, (item) => {
+            item.template = command.template
+            if (command.description !== undefined) item.description = command.description
+            if (command.agent !== undefined) item.agent = command.agent
+            if (command.model !== undefined) {
+              const model = ModelV2.parse(command.model)
+              item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
+            }
+            if (command.variant !== undefined && item.model !== undefined) {
+              item.model.variant = ModelV2.VariantID.make(command.variant)
+            }
+            if (command.subtask !== undefined) item.subtask = command.subtask
+          })
+        }
+      }
+    })
+  }),
+})
+
+function loadDirectory(fs: FSUtil.Interface, directory: string) {
+  return Effect.gen(function* () {
+    const files = yield* fs
+      .glob("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
+      .pipe(Effect.catch(() => Effect.succeed([] as string[])))
+    return yield* Effect.forEach(files.toSorted(), (filepath) =>
+      fs.readFileStringSafe(filepath).pipe(
+        Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
+        Effect.catch(() => Effect.succeed(undefined)),
+      ),
+    ).pipe(
+      Effect.map((commands) =>
+        commands.filter((command): command is { name: string; info: ConfigCommand.Info } => command !== undefined),
+      ),
+    )
+  })
+}
+
+function decode(directory: string, filepath: string, content: string) {
+  const markdown = ConfigMarkdown.parseOption(content)
+  if (!markdown) return
+  const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
+  if (!info) return
+  return {
+    name: path
+      .relative(directory, filepath)
+      .replaceAll("\\", "/")
+      .replace(/^(command|commands)\//, "")
+      .replace(/\.md$/, ""),
+    info,
+  }
+}

+ 2 - 0
packages/core/src/location-layer.ts

@@ -4,6 +4,7 @@ import { Policy } from "./policy"
 import { Config } from "./config"
 import { PluginV2 } from "./plugin"
 import { Catalog } from "./catalog"
+import { CommandV2 } from "./command"
 import { AgentV2 } from "./agent"
 import { PluginBoot } from "./plugin/boot"
 import { Project } from "./project"
@@ -34,6 +35,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       ProjectReference.locationLayer,
       PluginV2.locationLayer,
       Catalog.locationLayer,
+      CommandV2.locationLayer,
       AgentV2.locationLayer,
       PluginBoot.locationLayer,
       PermissionV2.locationLayer,

+ 7 - 0
packages/core/src/plugin/boot.ts

@@ -4,8 +4,10 @@ import { Context, Deferred, Effect, Layer } from "effect"
 import { Auth } from "../auth"
 import { AgentV2 } from "../agent"
 import { Catalog } from "../catalog"
+import { CommandV2 } from "../command"
 import { Config } from "../config"
 import { ConfigAgentPlugin } from "../config/plugin/agent"
+import { ConfigCommandPlugin } from "../config/plugin/command"
 import { ConfigSkillPlugin } from "../config/plugin/skill"
 import { EventV2 } from "../event"
 import { FSUtil } from "../fs-util"
@@ -26,6 +28,7 @@ type Plugin = {
   id: PluginV2.ID
   effect: PluginV2.Effect<
     | Catalog.Service
+    | CommandV2.Service
     | Auth.Service
     | AgentV2.Service
     | Npm.Service
@@ -50,6 +53,7 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const catalog = yield* Catalog.Service
+    const commands = yield* CommandV2.Service
     const plugin = yield* PluginV2.Service
     const accounts = yield* Auth.Service
     const agents = yield* AgentV2.Service
@@ -68,6 +72,7 @@ export const layer = Layer.effect(
         id: input.id,
         effect: input.effect.pipe(
           Effect.provideService(Catalog.Service, catalog),
+          Effect.provideService(CommandV2.Service, commands),
           Effect.provideService(Auth.Service, accounts),
           Effect.provideService(AgentV2.Service, agents),
           Effect.provideService(Config.Service, config),
@@ -93,6 +98,7 @@ export const layer = Layer.effect(
       yield* add(ModelsDevPlugin)
       yield* add(ConfigProviderPlugin.Plugin)
       yield* add(ConfigAgentPlugin.Plugin)
+      yield* add(ConfigCommandPlugin.Plugin)
       yield* add(ConfigSkillPlugin.Plugin)
     }).pipe(Effect.withSpan("PluginBoot.boot"))
 
@@ -110,6 +116,7 @@ export const layer = Layer.effect(
 
 export const locationLayer = layer.pipe(
   Layer.provideMerge(Catalog.locationLayer),
+  Layer.provideMerge(CommandV2.locationLayer),
   Layer.provideMerge(Config.locationLayer),
   Layer.provideMerge(AgentV2.locationLayer),
   Layer.provideMerge(SkillV2.locationLayer),

+ 1 - 0
packages/core/src/v1/config/command.ts

@@ -7,6 +7,7 @@ export const Info = Schema.Struct({
   description: Schema.optional(Schema.String),
   agent: Schema.optional(Schema.String),
   model: Schema.optional(Schema.String),
+  variant: Schema.optional(Schema.String),
   subtask: Schema.optional(Schema.Boolean),
 })
 export type Info = Schema.Schema.Type<typeof Info>

+ 1 - 0
packages/core/src/v1/config/migrate.ts

@@ -61,6 +61,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
       buffer: info.compaction.reserved,
     },
     skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
+    commands: info.command,
     instructions: info.instructions,
     references: info.reference,
     plugins: info.plugin?.map((plugin) =>

+ 56 - 0
packages/core/test/command.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { CommandV2 } from "@opencode-ai/core/command"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { testEffect } from "./lib/effect"
+
+const it = testEffect(CommandV2.locationLayer)
+
+describe("CommandV2", () => {
+  it.effect("applies command transforms and preserves later overrides", () =>
+    Effect.gen(function* () {
+      const command = yield* CommandV2.Service
+      const transform = yield* command.transform()
+      yield* transform((editor) => {
+        editor.update("review", (command) => {
+          command.template = "First"
+          command.description = "Review code"
+        })
+        editor.update("review", (command) => {
+          command.template = "Second"
+          command.model = {
+            id: ModelV2.ID.make("claude"),
+            providerID: ProviderV2.ID.make("anthropic"),
+            variant: ModelV2.VariantID.make("high"),
+          }
+        })
+      })
+
+      expect(yield* command.get("review")).toEqual(
+        new CommandV2.Info({
+          name: "review",
+          template: "Second",
+          description: "Review code",
+          model: {
+            id: ModelV2.ID.make("claude"),
+            providerID: ProviderV2.ID.make("anthropic"),
+            variant: ModelV2.VariantID.make("high"),
+          },
+        }),
+      )
+      expect(yield* command.list()).toEqual([
+        new CommandV2.Info({
+          name: "review",
+          template: "Second",
+          description: "Review code",
+          model: {
+            id: ModelV2.ID.make("claude"),
+            providerID: ProviderV2.ID.make("anthropic"),
+            variant: ModelV2.VariantID.make("high"),
+          },
+        }),
+      ])
+    }),
+  )
+})

+ 81 - 0
packages/core/test/config/command.test.ts

@@ -0,0 +1,81 @@
+import fs from "fs/promises"
+import path from "path"
+import { describe, expect } from "bun:test"
+import { Effect, Layer, Schema } from "effect"
+import { CommandV2 } from "@opencode-ai/core/command"
+import { Config } from "@opencode-ai/core/config"
+import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { tmpdir } from "../fixture/tmpdir"
+import { testEffect } from "../lib/effect"
+
+const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
+const decode = Schema.decodeUnknownSync(Config.Info)
+
+describe("ConfigCommandPlugin.Plugin", () => {
+  it.live("loads inline and file-based commands in config order", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          yield* Effect.promise(async () => {
+            await fs.mkdir(path.join(tmp.path, "commands", "nested"), { recursive: true })
+            await fs.writeFile(
+              path.join(tmp.path, "commands", "review.md"),
+              `---
+description: File review
+agent: reviewer
+model: anthropic/claude
+variant: high
+subtask: true
+---
+Review files`,
+            )
+            await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
+            await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
+          })
+
+          const command = yield* CommandV2.Service
+          yield* ConfigCommandPlugin.Plugin.effect.pipe(
+            Effect.provideService(CommandV2.Service, command),
+            Effect.provideService(
+              Config.Service,
+              Config.Service.of({
+                entries: () =>
+                  Effect.succeed([
+                    new Config.Document({
+                      type: "document",
+                      info: decode({ commands: { review: { template: "Inline review" } } }),
+                    }),
+                    new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
+                  ]),
+              }),
+            ),
+          )
+
+          expect(yield* command.list()).toEqual([
+            new CommandV2.Info({
+              name: "review",
+              template: "Review files",
+              description: "File review",
+              agent: "reviewer",
+              model: {
+                providerID: ProviderV2.ID.make("anthropic"),
+                id: ModelV2.ID.make("claude"),
+                variant: ModelV2.VariantID.make("high"),
+              },
+              subtask: true,
+            }),
+            new CommandV2.Info({ name: "empty", template: "" }),
+            new CommandV2.Info({ name: "nested/docs", template: "Write docs" }),
+          ])
+        }),
+      ),
+    ),
+  )
+})

+ 28 - 0
packages/core/test/config/config.test.ts

@@ -100,6 +100,34 @@ describe("Config", () => {
     }),
   )
 
+  it.effect("migrates v1 command configuration", () =>
+    Effect.sync(() => {
+      expect(
+        ConfigMigrateV1.migrate({
+          command: {
+            review: {
+              template: "Review changes",
+              description: "Review code",
+              agent: "reviewer",
+              model: "anthropic/claude",
+              variant: "high",
+              subtask: true,
+            },
+          },
+        }).commands,
+      ).toEqual({
+        review: {
+          template: "Review changes",
+          description: "Review code",
+          agent: "reviewer",
+          model: "anthropic/claude",
+          variant: "high",
+          subtask: true,
+        },
+      })
+    }),
+  )
+
   it.live("returns an empty configuration when directory files do not exist", () =>
     Effect.acquireRelease(
       Effect.promise(() => tmpdir()),