feat(core): add command registry (#30624)

This commit is contained in:
Dax
2026-06-04 02:57:43 -04:00
committed by GitHub
parent 70bb710715
commit 1ff19103a2
150 changed files with 4753 additions and 2657 deletions
+7 -1
View File
@@ -6,6 +6,7 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@@ -187,7 +188,12 @@ describe("CatalogV2", () => {
yield* events.publish(
PluginV2.Event.Added,
{ id: PluginV2.ID.make("test-transform") },
{ location: { directory: AbsolutePath.make("other") } },
{
location: new Location.Info({
directory: AbsolutePath.make("other"),
project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
}),
},
)
yield* Effect.yieldNow
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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()),
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const directory = AbsolutePath.make("/repo/packages/app")
const project = AbsolutePath.make("/repo")
const it = testEffect(
CommandV2.locationLayer.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
),
),
),
)
describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* CommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
)
expect(yield* command.get("init")).toMatchObject({
name: "init",
description: "guided AGENTS.md setup",
})
expect((yield* command.get("init"))?.template).toContain("`/repo`")
expect(yield* command.get("review")).toMatchObject({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
subtask: true,
})
}),
)
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { testEffect } from "../lib/effect"
const it = testEffect(
SkillV2.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(SkillDiscovery.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
),
)
describe("SkillPlugin.Plugin", () => {
it.effect("registers the built-in customize-opencode skill", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill))
expect(yield* skill.list()).toContainEqual(
expect.objectContaining({
name: "customize-opencode",
description: expect.stringContaining("opencode's own configuration"),
}),
)
}),
)
})
-108
View File
@@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer, Schema } from "effect"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)),
)
const encodeProvider = Schema.encodeSync(ProviderV2.PublicInfo)
const encodeModel = Schema.encodeSync(ModelV2.PublicInfo)
describe("public catalog DTOs", () => {
test("provider DTO excludes credentials and internal settings", () => {
const providerID = ProviderV2.ID.make("test")
const encoded = encodeProvider(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
enabled: { via: "account", service: "test-account" },
env: ["TEST_API_KEY"],
api: { type: "native", url: "https://example.com", settings: { apiKey: "settings-secret" } },
request: {
headers: { Authorization: "Bearer header-secret", "x-api-key": "header-secret" },
body: { apiKey: "body-secret", account: "account-body-secret" },
},
}),
),
)
expect(encoded).toEqual({
id: "test",
name: "test",
enabled: { via: "account", service: "test-account" },
env: ["TEST_API_KEY"],
api: { type: "native", url: "https://example.com" },
})
expect(JSON.stringify(encoded)).not.toMatch(/Authorization|x-api-key|apiKey|account-body-secret|settings-secret/)
})
test("provider DTO excludes custom enabled metadata", () => {
const providerID = ProviderV2.ID.make("custom")
const encoded = encodeProvider(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
enabled: { via: "custom", data: { apiKey: "custom-secret" } },
}),
),
)
expect(encoded.enabled).toEqual({ via: "custom" })
expect(JSON.stringify(encoded)).not.toContain("custom-secret")
})
it.effect("model DTO excludes resolved provider requests and variant requests", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "account", service: "test-account" }
provider.api = { type: "native", url: "https://example.com", settings: { apiKey: "settings-secret" } }
provider.request.headers.Authorization = "Bearer provider-secret"
provider.request.body.apiKey = "provider-body-secret"
provider.request.body.account = "account-body-secret"
})
catalog.model.update(providerID, modelID, (model) => {
model.request.headers["x-api-key"] = "model-header-secret"
model.request.body.apiKey = "model-body-secret"
model.variants.push({
id: ModelV2.VariantID.make("fast"),
headers: { Authorization: "Bearer variant-secret" },
body: { apiKey: "variant-body-secret" },
})
})
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.request.headers.Authorization).toBe("Bearer provider-secret")
expect(model.request.headers["x-api-key"]).toBe("model-header-secret")
expect(model.request.body.apiKey).toBe("model-body-secret")
expect(model.request.body.account).toBe("account-body-secret")
expect(model.api).toHaveProperty("settings.apiKey", "settings-secret")
const encoded = encodeModel(ModelV2.toPublic(model))
expect(encoded.api).toEqual({ id: "model", type: "native", url: "https://example.com" })
expect(encoded.variants).toEqual([{ id: "fast" }])
expect(encoded).not.toHaveProperty("request")
expect(JSON.stringify(encoded)).not.toMatch(
/Authorization|x-api-key|apiKey|account-body-secret|provider-secret|model-header-secret|variant-secret|settings-secret/,
)
}),
)
})