| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- 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")])
- }),
- ),
- ),
- )
- })
|