diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts new file mode 100644 index 00000000000..b9a5ae15d8b --- /dev/null +++ b/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("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 +} + +export type Editor = { + list: () => readonly Info[] + get: (name: string) => Info | undefined + update: (name: string, update: (command: Draft) => void) => void + remove: (name: string) => void +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Command") {} + +export const layer = Layer.effect( + Service, + Effect.sync(() => { + const state = State.create({ + 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 diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 81fde962095..47ba3036984 100644 --- a/packages/core/src/config.ts +++ b/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("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", }), diff --git a/packages/core/src/config/command.ts b/packages/core/src/config/command.ts new file mode 100644 index 00000000000..394079b1e98 --- /dev/null +++ b/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("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), +}) {} diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts new file mode 100644 index 00000000000..d9055295452 --- /dev/null +++ b/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, + } +} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index c6f3cada8a6..a55321e5149 100644 --- a/packages/core/src/location-layer.ts +++ b/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()(" ProjectReference.locationLayer, PluginV2.locationLayer, Catalog.locationLayer, + CommandV2.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, PermissionV2.locationLayer, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 554547fc8b0..be62032f543 100644 --- a/packages/core/src/plugin/boot.ts +++ b/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), diff --git a/packages/core/src/v1/config/command.ts b/packages/core/src/v1/config/command.ts index 37bbdc44f3f..281d5309109 100644 --- a/packages/core/src/v1/config/command.ts +++ b/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 diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 9b123ecd1d0..5dea17a4ab7 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/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) => diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts new file mode 100644 index 00000000000..f2175743e42 --- /dev/null +++ b/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"), + }, + }), + ]) + }), + ) +}) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts new file mode 100644 index 00000000000..da3bb749b45 --- /dev/null +++ b/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" }), + ]) + }), + ), + ), + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 5b218dae521..465a4154755 100644 --- a/packages/core/test/config/config.test.ts +++ b/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()),