mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8937c67f4 | ||
|
|
6df4ef0567 |
@@ -1,16 +1,9 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory as ConfigDirectory,
|
||||
Document,
|
||||
type Entry,
|
||||
Info,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, type Entry, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -31,26 +24,10 @@ import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
const failedUrls = new Set<string>()
|
||||
let pulls = 0
|
||||
const discoveryLayer = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const emptyDiscovery = SkillDiscovery.Service.of({ pull: () => Effect.succeed([]) })
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
|
||||
discoveryLayer,
|
||||
watcherLayer,
|
||||
),
|
||||
Layer.merge(AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])), watcherLayer),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
@@ -65,7 +42,12 @@ description: ${description}
|
||||
)
|
||||
}
|
||||
|
||||
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
|
||||
const startEntries = Effect.fnUntraced(function* (
|
||||
entries: Entry[],
|
||||
directory: string,
|
||||
home = directory,
|
||||
discovery = emptyDiscovery,
|
||||
) {
|
||||
const service = yield* Skill.Service
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -77,13 +59,14 @@ const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: s
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer(entries)),
|
||||
Effect.provideService(SkillDiscovery.Service, discovery),
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
return service
|
||||
})
|
||||
|
||||
const start = (skills: string[], directory: string) =>
|
||||
const start = (skills: string[], directory: string, discovery = emptyDiscovery) =>
|
||||
startEntries(
|
||||
[
|
||||
new Document({
|
||||
@@ -92,6 +75,8 @@ const start = (skills: string[], directory: string) =>
|
||||
}),
|
||||
],
|
||||
directory,
|
||||
directory,
|
||||
discovery,
|
||||
)
|
||||
|
||||
const discover = (directory: string, global: string) =>
|
||||
@@ -130,14 +115,13 @@ function emitAndWait(update: Watcher.Update) {
|
||||
}
|
||||
|
||||
describe("SkillFile.parse", () => {
|
||||
it.effect("parses root and nested skill ids and metadata flags", () =>
|
||||
Effect.sync(() => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
test("parses root and nested skill ids and metadata flags", () => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
name: Manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
@@ -145,45 +129,41 @@ metadata:
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
),
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(SkillFile.parse("/repo/skills/manual", "/repo/skills/manual/SKILL.md", "# manual")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("manual"), name: Skill.Name.make("manual") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
),
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(SkillFile.parse("/repo/skills/manual", "/repo/skills/manual/SKILL.md", "# manual")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("manual"), name: Skill.Name.make("manual") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.live("maps config entry types to skill directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const claude = path.join(tmp.path, "claude")
|
||||
@@ -205,7 +185,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
[
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
|
||||
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
|
||||
],
|
||||
directory,
|
||||
@@ -219,10 +199,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("loads directory and individual downloaded skill roots with later-source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const first = path.join(tmp.path, "first")
|
||||
@@ -235,30 +212,32 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
await write(second, "deploy", "Deploy")
|
||||
await write(second, "review", "Second")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [
|
||||
AbsolutePath.make(path.join(second, "deploy")),
|
||||
AbsolutePath.make(path.join(second, "review")),
|
||||
])
|
||||
const pulls: string[] = []
|
||||
const discovery = SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls.push(url)
|
||||
return Effect.succeed([
|
||||
AbsolutePath.make(path.join(second, "deploy")),
|
||||
AbsolutePath.make(path.join(second, "review")),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path, discovery)
|
||||
expect((yield* skill.list()).map((item) => item.id).toSorted()).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Deploy")
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
|
||||
expect(pulls).toBe(1)
|
||||
expect(pulls).toEqual(["https://example.test/skills/"])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("prefers a worktree skill over the parent checkout copy", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const checkout = path.join(tmp.path, "repo")
|
||||
@@ -286,10 +265,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -297,21 +273,17 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
await write(tmp.path, "review", "Available")
|
||||
})
|
||||
const url = "https://unreachable.example.test/skills/"
|
||||
failedUrls.add(url)
|
||||
const discovery = SkillDiscovery.Service.of({ pull: () => Effect.die(`failed to pull ${url}`) })
|
||||
|
||||
const skill = yield* start([tmp.path, url], tmp.path)
|
||||
const skill = yield* start([tmp.path, url], tmp.path, discovery)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
|
||||
failedUrls.delete(url)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rescans directory sources when watched files change", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -345,10 +317,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
@@ -375,10 +344,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("reloads symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
@@ -419,10 +385,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("follows missing source directories as their parents appear", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
|
||||
@@ -5,9 +5,7 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
|
||||
- `hook` installs behavior at an OpenCode extension point.
|
||||
- `reload` reruns every transform hook for a stateful domain.
|
||||
|
||||
The Promise API uses Promises instead of Effects for setup, runtime hook
|
||||
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
|
||||
draft callbacks remain synchronous.
|
||||
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
|
||||
|
||||
## Defining A Plugin
|
||||
|
||||
@@ -48,15 +46,12 @@ await registration.dispose()
|
||||
|
||||
## Transform Hooks
|
||||
|
||||
Transform hooks contribute to stateful domains. The draft editor is synchronous,
|
||||
so load asynchronous data before registering a transform or reloading its domain:
|
||||
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
|
||||
|
||||
```ts
|
||||
const description = await loadReviewerDescription()
|
||||
|
||||
await ctx.agent.transform((agent) => {
|
||||
agent.update("reviewer", (item) => {
|
||||
item.description = description
|
||||
item.description = "Reviews code for regressions"
|
||||
item.mode = "subagent"
|
||||
})
|
||||
})
|
||||
@@ -69,12 +64,8 @@ ctx.agent.transform
|
||||
ctx.catalog.transform
|
||||
ctx.command.transform
|
||||
ctx.integration.transform
|
||||
ctx.mcp.transform
|
||||
ctx.reference.transform
|
||||
ctx.skill.transform
|
||||
ctx.tool.transform
|
||||
ctx.vcs.transform
|
||||
ctx.websearch.transform
|
||||
```
|
||||
|
||||
## Runtime Hooks
|
||||
@@ -90,7 +81,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
|
||||
|
||||
await ctx.aisdk.hook("language", (event) => {
|
||||
if (event.model.providerID !== "xai") return
|
||||
event.language = event.sdk.responses(event.model.modelID)
|
||||
event.language = event.sdk.responses(event.model.api.id)
|
||||
})
|
||||
```
|
||||
|
||||
@@ -103,15 +94,14 @@ await ctx.session.hook("context", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
Promise tools use complete executable tool values with async executors:
|
||||
Promise tools use executable tool values with async executors. Registration
|
||||
supplies the tool's name and options separately:
|
||||
|
||||
```ts
|
||||
import { Schema } from "effect"
|
||||
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "echo",
|
||||
options: { codemode: false },
|
||||
tools.add("echo", {
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
@@ -142,10 +132,6 @@ ctx.agent.reload()
|
||||
ctx.catalog.reload()
|
||||
ctx.command.reload()
|
||||
ctx.integration.reload()
|
||||
ctx.mcp.reload()
|
||||
ctx.reference.reload()
|
||||
ctx.skill.reload()
|
||||
ctx.tool.reload()
|
||||
ctx.vcs.reload()
|
||||
ctx.websearch.reload()
|
||||
```
|
||||
|
||||
@@ -31,9 +31,7 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
|
||||
|
||||
## Transform Hooks
|
||||
|
||||
Transform hooks contribute to stateful domains. Their draft callbacks are
|
||||
synchronous, so load effectful data before registering a transform or reloading
|
||||
its domain:
|
||||
Transform hooks contribute to stateful domains:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
@@ -54,12 +52,8 @@ ctx.agent.transform
|
||||
ctx.catalog.transform
|
||||
ctx.command.transform
|
||||
ctx.integration.transform
|
||||
ctx.mcp.transform
|
||||
ctx.reference.transform
|
||||
ctx.skill.transform
|
||||
ctx.tool.transform
|
||||
ctx.vcs.transform
|
||||
ctx.websearch.transform
|
||||
```
|
||||
|
||||
## Runtime Hooks
|
||||
@@ -78,12 +72,10 @@ yield *
|
||||
)
|
||||
|
||||
yield *
|
||||
ctx.aisdk.hook("language", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.model.providerID !== "xai") return
|
||||
event.language = event.sdk.responses(event.model.modelID)
|
||||
}),
|
||||
)
|
||||
ctx.aisdk.hook("language", (event) => {
|
||||
if (event.model.providerID !== "xai") return
|
||||
event.language = event.sdk.responses(event.model.api.id)
|
||||
})
|
||||
```
|
||||
|
||||
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
|
||||
@@ -125,10 +117,6 @@ ctx.agent.reload()
|
||||
ctx.catalog.reload()
|
||||
ctx.command.reload()
|
||||
ctx.integration.reload()
|
||||
ctx.mcp.reload()
|
||||
ctx.reference.reload()
|
||||
ctx.skill.reload()
|
||||
ctx.tool.reload()
|
||||
ctx.vcs.reload()
|
||||
ctx.websearch.reload()
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user