mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:03:30 +00:00
432 lines
17 KiB
TypeScript
432 lines
17 KiB
TypeScript
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { describe, expect } 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 { 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"
|
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
|
import { Bus } from "@opencode-ai/core/bus"
|
|
import { Credential } from "@opencode-ai/core/credential"
|
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
import { Global } from "@opencode-ai/util/global"
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { Skill } from "@opencode-ai/core/skill"
|
|
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
|
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
|
import { tmpdir } from "../fixture/tmpdir"
|
|
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 watcherLayer = Watcher.testLayer
|
|
const it = testEffect(
|
|
Layer.mergeAll(
|
|
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
|
|
discoveryLayer,
|
|
watcherLayer,
|
|
),
|
|
)
|
|
const decode = Schema.decodeUnknownSync(Info)
|
|
|
|
function write(directory: string, name: string, description: string) {
|
|
return fs.writeFile(
|
|
path.join(directory, name, "SKILL.md"),
|
|
`---
|
|
name: ${name}
|
|
description: ${description}
|
|
---
|
|
# ${name}`,
|
|
)
|
|
}
|
|
|
|
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
|
|
const service = yield* Skill.Service
|
|
yield* ConfigSkillPlugin.Plugin.effect(
|
|
host({
|
|
skill: {
|
|
list: () => Effect.die("unused skill.list"),
|
|
transform: service.transform,
|
|
reload: service.reload,
|
|
},
|
|
}),
|
|
).pipe(
|
|
Effect.provide(Config.testLayer(entries)),
|
|
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) =>
|
|
startEntries(
|
|
[
|
|
new Document({
|
|
type: "document",
|
|
info: decode({ skills }),
|
|
}),
|
|
],
|
|
directory,
|
|
)
|
|
|
|
const discover = (directory: string, global: string) =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.Service
|
|
return yield* config.entries()
|
|
}).pipe(
|
|
Effect.provide(
|
|
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
|
[
|
|
Location.node,
|
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
|
],
|
|
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
|
[Credential.node, emptyCredentialNode],
|
|
[WellKnown.node, emptyWellknownNode],
|
|
[Watcher.node, Watcher.testLayer],
|
|
]),
|
|
),
|
|
)
|
|
|
|
function emitAndWait(update: Watcher.Update) {
|
|
return Effect.gen(function* () {
|
|
const watcher = yield* Watcher.Test
|
|
const bus = yield* Bus.Service
|
|
const deferred = yield* Deferred.make<void>()
|
|
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
|
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
|
|
Effect.forkScoped,
|
|
)
|
|
yield* Effect.yieldNow
|
|
yield* watcher.emit(update)
|
|
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
|
|
yield* Fiber.interrupt(fiber)
|
|
})
|
|
}
|
|
|
|
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",
|
|
`---
|
|
name: Manual
|
|
description: Manual only
|
|
metadata:
|
|
opencode/slash: "true"
|
|
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(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.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
const claude = path.join(tmp.path, "claude")
|
|
const agents = path.join(tmp.path, "agents")
|
|
const opencode = path.join(tmp.path, "opencode")
|
|
const home = path.join(tmp.path, "home")
|
|
const directory = path.join(tmp.path, "project")
|
|
const expected = [
|
|
path.join(claude, "skills"),
|
|
path.join(agents, "skills"),
|
|
path.join(opencode, "skill"),
|
|
path.join(opencode, "skills"),
|
|
path.join(home, "shared"),
|
|
path.join(directory, "relative"),
|
|
]
|
|
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
|
|
|
|
yield* startEntries(
|
|
[
|
|
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 Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
|
|
],
|
|
directory,
|
|
home,
|
|
)
|
|
const watcher = yield* Watcher.Test
|
|
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("loads directory and URL sources with later-source precedence", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
const first = path.join(tmp.path, "first")
|
|
const second = path.join(tmp.path, "second")
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(first, "review"), { recursive: true })
|
|
await fs.mkdir(path.join(second, "review"), { recursive: true })
|
|
await write(first, "review", "First")
|
|
await write(second, "review", "Second")
|
|
})
|
|
pulls = 0
|
|
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
|
|
|
|
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
|
|
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
|
|
expect(pulls).toBe(1)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("prefers a worktree skill over the parent checkout copy", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
const checkout = path.join(tmp.path, "repo")
|
|
const worktree = path.join(checkout, ".worktrees", "feature")
|
|
const parentSkills = path.join(checkout, ".agents", "skills")
|
|
const worktreeSkills = path.join(worktree, ".agents", "skills")
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
|
|
await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
|
|
await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
|
|
await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
|
|
await write(parentSkills, "review", "Parent checkout")
|
|
await write(worktreeSkills, "review", "Worktree")
|
|
})
|
|
|
|
const entries = yield* discover(worktree, path.join(tmp.path, "global"))
|
|
const skill = yield* startEntries(entries, worktree)
|
|
const review = (yield* skill.list()).find((item) => item.id === "review")
|
|
|
|
expect(review?.description).toBe("Worktree")
|
|
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("keeps directory skills when a URL source fails", () =>
|
|
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, "review"), { recursive: true })
|
|
await write(tmp.path, "review", "Available")
|
|
})
|
|
const url = "https://unreachable.example.test/skills/"
|
|
failedUrls.add(url)
|
|
|
|
const skill = yield* start([tmp.path, url], tmp.path)
|
|
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.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
|
await write(tmp.path, "deploy", "Initial")
|
|
})
|
|
const skill = yield* start([tmp.path], tmp.path)
|
|
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
|
|
|
|
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
|
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
|
|
yield* emitAndWait({ type: "update", path: deploy })
|
|
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
|
|
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
|
await write(tmp.path, "review", "Review")
|
|
})
|
|
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
|
|
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
|
Skill.ID.make("deploy"),
|
|
Skill.ID.make("review"),
|
|
])
|
|
|
|
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
|
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
|
|
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("watches canonical directories behind symlinked skills", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
const source = path.join(tmp.path, "source")
|
|
const target = path.join(tmp.path, "target", "bro")
|
|
const file = path.join(target, "SKILL.md")
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(source, { recursive: true })
|
|
await fs.mkdir(target, { recursive: true })
|
|
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
|
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
|
|
})
|
|
|
|
const skill = yield* start([source], tmp.path)
|
|
const watcher = yield* Watcher.Test
|
|
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
|
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
|
|
|
|
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
|
yield* emitAndWait({ type: "update", path: file })
|
|
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("reloads symlinked sources when their target changes", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
const source = path.join(tmp.path, "source")
|
|
const first = path.join(tmp.path, "first")
|
|
const second = path.join(tmp.path, "second")
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
|
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
|
await write(first, "bro", "First")
|
|
await write(second, "bro", "Second")
|
|
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
|
|
})
|
|
|
|
const skill = yield* start([source], tmp.path)
|
|
const watcher = yield* Watcher.Test
|
|
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
|
expect(yield* watcher.subscriptions()).toEqual([
|
|
{ path: first, type: "directory" },
|
|
{ path: source, type: "file" },
|
|
])
|
|
|
|
yield* Effect.promise(async () => {
|
|
await fs.unlink(source)
|
|
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
|
|
})
|
|
yield* emitAndWait({ type: "update", path: source })
|
|
|
|
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
|
expect(yield* watcher.subscriptions()).toEqual([
|
|
{ path: first, type: "directory" },
|
|
{ path: source, type: "file" },
|
|
{ path: second, type: "directory" },
|
|
{ path: source, type: "file" },
|
|
])
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("follows missing source directories as their parents appear", () =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(
|
|
Effect.flatMap((tmp) =>
|
|
Effect.gen(function* () {
|
|
const source = path.join(tmp.path, "generated", "skills")
|
|
const skill = yield* start([source], tmp.path)
|
|
const watcher = yield* Watcher.Test
|
|
expect(yield* skill.list()).toEqual([])
|
|
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
|
|
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
|
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
|
|
await write(source, "deploy", "Deploy")
|
|
})
|
|
yield* emitAndWait({ type: "create", path: source })
|
|
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
})
|