From 82a1311fa34ccec704e8de4520109453aebd1617 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 18:51:19 -0400 Subject: [PATCH 1/3] refactor(core): skill service stores values, config plugin owns the filesystem --- packages/core/src/config/plugin/skill-file.ts | 48 ++ packages/core/src/config/plugin/skill.ts | 153 +++++- packages/core/src/plugin/host.ts | 6 +- packages/core/src/plugin/internal.ts | 8 + packages/core/src/plugin/skill.ts | 36 +- packages/core/src/skill.ts | 215 +------- .../skills/first-source/first/SKILL.md | 5 + .../skills/second-source/second/SKILL.md | 5 + packages/core/test/config/reload.test.ts | 14 +- packages/core/test/config/skill.test.ts | 257 +++++++--- packages/core/test/plugin/fixture.ts | 6 +- packages/core/test/skill.test.ts | 480 +++--------------- packages/core/test/tool-skill.test.ts | 1 - packages/plugin/src/effect/skill.ts | 8 +- packages/plugin/src/promise/skill.ts | 7 +- 15 files changed, 523 insertions(+), 726 deletions(-) create mode 100644 packages/core/src/config/plugin/skill-file.ts create mode 100644 packages/core/test/config/fixture/skills/first-source/first/SKILL.md create mode 100644 packages/core/test/config/fixture/skills/second-source/second/SKILL.md diff --git a/packages/core/src/config/plugin/skill-file.ts b/packages/core/src/config/plugin/skill-file.ts new file mode 100644 index 000000000000..578e978dfe32 --- /dev/null +++ b/packages/core/src/config/plugin/skill-file.ts @@ -0,0 +1,48 @@ +export * as SkillFile from "./skill-file" + +import path from "path" +import { Schema } from "effect" +import { ConfigMarkdown } from "../markdown" +import { AbsolutePath } from "../../schema" +import { Skill } from "../../skill" + +const Frontmatter = Schema.Struct({ + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + slash: Schema.Boolean.pipe(Schema.optional), + metadata: Schema.Unknown.pipe(Schema.optional), +}) +const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) + +const metadataBoolean = (metadata: unknown, key: string) => { + if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { + return undefined + } + const value = Reflect.get(metadata, key) + if (typeof value === "boolean") return value + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "true") return true + if (normalized === "false") return false + return undefined +} + +export function parse(directory: string, filepath: string, content: string): Skill.Info | undefined { + const markdown = ConfigMarkdown.parseOption(content) + if (!markdown) return undefined + const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined + if (!frontmatter) return undefined + const id = + path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath)) + const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash + const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke") + return { + id: Skill.ID.make(id), + name: Skill.Name.make(frontmatter.name ?? id), + ...(frontmatter.description === undefined ? {} : { description: frontmatter.description }), + ...(slash === undefined ? {} : { slash }), + ...(autoinvoke === undefined ? {} : { autoinvoke }), + location: AbsolutePath.make(filepath), + content: markdown.content, + } +} diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 427a7f974b38..5279e5d023bf 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,64 +1,173 @@ export * as ConfigSkillPlugin from "./skill" import { define } from "@opencode-ai/plugin/effect/plugin" +import type { Entry } from "@opencode-ai/schema/config" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" import path from "path" -import { Effect, Stream } from "effect" +import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect" import { Config } from "../../config" +import { Watcher } from "../../filesystem/watcher" +import { Location } from "../../location" import { AbsolutePath } from "../../schema" import { Skill } from "../../skill" -import { Global } from "@opencode-ai/util/global" -import { Location } from "../../location" +import { SkillDiscovery } from "../../skill/discovery" +import { SkillFile } from "./skill-file" + +type Source = Skill.DirectorySource | Skill.UrlSource export const Plugin = define({ id: "opencode.config.skill", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const discovery = yield* SkillDiscovery.Service + const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service - const loaded = { entries: yield* config.entries() } - yield* ctx.skill.transform((draft) => { + const watcher = yield* Watcher.Service + const loaded: { entries: Entry[]; skills: Skill.Info[] } = { + entries: yield* config.entries(), + skills: [], + } + const watches = yield* FiberMap.make() + const changes = yield* PubSub.sliding(1) + const lock = Semaphore.makeUnsafe(1) + + const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) { + const target = path.resolve(directory) + const updates = yield* watcher.subscribe({ path: target, type }) + yield* FiberMap.run( + watches, + `${type}:${target}`, + updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))), + { onlyIfMissing: true, startImmediately: true }, + ) + }) + + function firstMissing(target: string): Effect.Effect { + const parent = path.dirname(target) + if (parent === target) return Effect.succeed(undefined) + return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent)))) + } + + const watchDirectory: (directory: string) => Effect.Effect = Effect.fn( + "ConfigSkillPlugin.watchDirectory", + )(function* (directory: string) { + const target = path.resolve(directory) + const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (resolved) { + yield* watch(resolved, "directory") + if (resolved !== target) yield* watch(target, "file") + return resolved === target ? [target] : [target, resolved] + } + const missing = yield* firstMissing(target) + if (missing) yield* watch(missing, "file") + if ( + yield* fs.realPath(directory).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + ) { + if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`) + return yield* watchDirectory(directory) + } + return [target] + }) + + const sources = () => { + const result: Source[] = [] + const add = (source: Source) => { + if (result.some((item) => Skill.Source.equals(item, source))) return + result.push(source) + } const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : [])) const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : [])) const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) for (const directory of [...claude, ...agents]) { - draft.source( - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join(directory, "skills")), - }), - ) + add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) })) } for (const directory of directories) { - draft.source( - Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), - ) - draft.source( - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join(directory, "skills")), - }), - ) + add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) })) + add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) })) } for (const item of items) { if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { - draft.source(Skill.UrlSource.make({ type: "url", url: item })) + add(Skill.UrlSource.make({ type: "url", url: item })) continue } const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item - draft.source( + add( Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), }), ) } + return result + } + + const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) { + const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) + const roots = (yield* Effect.forEach(directories, watchDirectory)).flat() + const skills: Skill.Info[] = [] + for (const directory of directories) { + const files = yield* fs + .scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + for (const filepath of files.toSorted()) { + const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath))) + if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory") + const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!content) continue + const skill = SkillFile.parse(directory, filepath, content) + if (skill) skills.push(skill) + } + } + yield* Effect.logDebug("skill source loaded", { + source: Skill.Source.key(source), + type: source.type, + directories, + skills: skills.map((skill) => skill.id), + }) + return skills + }) + + const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) { + yield* lock.withPermit( + Effect.gen(function* () { + yield* FiberMap.clear(watches) + const skills = new Map() + const current = sources() + for (const source of current) { + for (const skill of yield* load(source)) skills.set(skill.id, skill) + } + loaded.skills = Array.from(skills.values()) + if (file) { + yield* Effect.logInfo("skills rescanned", { + file, + sources: current.map(Skill.Source.key), + skills: loaded.skills.map((skill) => skill.id), + }) + } + }), + ) + }) + + yield* Stream.fromPubSub(changes).pipe( + Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh() + yield* ctx.skill.transform((draft) => { + for (const skill of loaded.skills) draft.add(skill) }) yield* ctx.event.subscribe().pipe( Stream.filter((event) => event.type === "config.updated"), Stream.runForEach(() => config.entries().pipe( Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(refresh()), Effect.andThen(ctx.skill.reload()), ), ), diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a563be057948..faee955c6624 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -290,8 +290,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p transform: (callback) => skill.transform((draft) => { callback({ - source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)), - list: draft.list, + list: () => mutable(draft.list()), + add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)), + update: draft.update, + remove: draft.remove, }) }), }, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2d2c4b3f27b4..f674c0ca9e21 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -39,6 +39,8 @@ import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" import { Shell } from "../shell" import { Skill } from "../skill" +import { SkillDiscovery } from "../skill/discovery" +import { Watcher } from "../filesystem/watcher" import { PatchTool } from "../tool/plugin/patch" import { EditTool } from "../tool/plugin/edit" import { GlobTool } from "../tool/plugin/glob" @@ -97,7 +99,9 @@ const services = Effect.fn("PluginInternal.services")(function* () { const instructions = yield* SessionInstructions.Service const shell = yield* Shell.Service const skill = yield* Skill.Service + const skillDiscovery = yield* SkillDiscovery.Service const tools = yield* Tool.Service + const watcher = yield* Watcher.Service const wellknown = yield* WellKnown.Service return Context.mergeAll( Context.make(Agent.Service, agent), @@ -130,7 +134,9 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(SessionInstructions.Service, instructions), Context.make(Shell.Service, shell), Context.make(Skill.Service, skill), + Context.make(SkillDiscovery.Service, skillDiscovery), Context.make(Tool.Service, tools), + Context.make(Watcher.Service, watcher), Context.make(WellKnown.Service, wellknown), ) }) @@ -170,7 +176,9 @@ export const requirements = LayerNode.group([ SessionInstructions.node, Shell.node, Skill.node, + SkillDiscovery.node, Tool.node, + Watcher.node, WellKnown.node, ]) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index e10ce3e848fe..04b6c3161248 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -28,29 +28,23 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const reportContent = yield* reportContentWithDiagnostics(ctx.app) yield* ctx.skill.transform((draft) => { - draft.source( - Skill.EmbeddedSource.make({ - type: "embedded", - skill: Skill.Info.make({ - id: Skill.ID.make("opencode"), - name: Skill.Name.make("OpenCode"), - description: OpencodeDescription, - location: AbsolutePath.make("/builtin/opencode.md"), - content: OpencodeContent, - }), + draft.add( + Skill.Info.make({ + id: Skill.ID.make("opencode"), + name: Skill.Name.make("OpenCode"), + description: OpencodeDescription, + location: AbsolutePath.make("/builtin/opencode.md"), + content: OpencodeContent, }), ) - draft.source( - Skill.EmbeddedSource.make({ - type: "embedded", - skill: Skill.Info.make({ - id: Skill.ID.make("report"), - name: Skill.Name.make("Report"), - description: REPORT_DESCRIPTION, - slash: true, - location: AbsolutePath.make("/builtin/report.md"), - content: reportContent, - }), + draft.add( + Skill.Info.make({ + id: Skill.ID.make("report"), + name: Skill.Name.make("Report"), + description: REPORT_DESCRIPTION, + slash: true, + location: AbsolutePath.make("/builtin/report.md"), + content: reportContent, }), ) }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 8b03c6bfd8d5..182cd1bc881f 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -2,17 +2,12 @@ export * as Skill from "./skill" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import path from "path" -import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect" +import { Context, Effect, Layer, Types } from "effect" import { Skill } from "@opencode-ai/schema/skill" import { Agent } from "./agent" -import { ConfigMarkdown } from "./config/markdown" import { Bus } from "./bus" -import { FSUtil } from "@opencode-ai/util/fs-util" import { Permission } from "./permission" -import { AbsolutePath } from "./schema" -import { SkillDiscovery } from "./skill/discovery" import { State } from "./state" -import { Watcher } from "./filesystem/watcher" export const DirectorySource = Skill.DirectorySource export type DirectorySource = Skill.DirectorySource @@ -57,38 +52,18 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray) => { ].join("\n") } -const Frontmatter = Schema.Struct({ - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - slash: Schema.Boolean.pipe(Schema.optional), - metadata: Schema.Unknown.pipe(Schema.optional), -}) -const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) - -const metadataBoolean = (metadata: unknown, key: string) => { - if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { - return undefined - } - const value = (metadata as { readonly [key: string]: unknown })[key] - if (typeof value === "boolean") return value - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "true") return true - if (normalized === "false") return false - return undefined -} - export type Data = { - sources: Types.DeepMutable[] + skills: Map> } export type Draft = { - source: (source: Source) => void - list: () => readonly Source[] + list: () => readonly Types.DeepMutable[] + add: (skill: Info) => void + update: (id: string, update: (skill: Types.DeepMutable) => void) => void + remove: (id: string) => void } export interface Interface extends State.Transformable { - readonly sources: () => Effect.Effect readonly list: () => Effect.Effect } @@ -97,179 +72,35 @@ export class Service extends Context.Service()("@opencode/Sk const layer = Layer.effect( Service, Effect.gen(function* () { - const discovery = yield* SkillDiscovery.Service - const fs = yield* FSUtil.Service const bus = yield* Bus.Service - const watcher = yield* Watcher.Service - const cache = new Map() - const watches = yield* FiberMap.make() - const lock = Semaphore.makeUnsafe(1) - const changes = yield* PubSub.unbounded() - - const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) { - const changed = yield* lock.withPermit( - Effect.gen(function* () { - const invalidated = Array.from(cache.entries()).filter(([, loaded]) => - loaded.paths.some((item) => FSUtil.overlaps(item, file)), - ) - if (invalidated.length === 0) return false - cache.clear() - yield* FiberMap.clear(watches) - yield* Effect.logInfo("skill cache invalidated", { - file, - sources: invalidated.map(([key]) => key), - skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)), - }) - return true - }), - ) - if (!changed) return - yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid) - }) - - yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true })) - - const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) { - const target = path.resolve(directory) - const updates = yield* watcher.subscribe( - type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" }, - ) - yield* FiberMap.run( - watches, - `${type}:${target}`, - updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))), - { - onlyIfMissing: true, - startImmediately: true, - }, - ) - }) - - function firstMissing(target: string): Effect.Effect { - const parent = path.dirname(target) - if (parent === target) return Effect.succeed(undefined) - return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent)))) - } - - const watchDirectory: (directory: string) => Effect.Effect = Effect.fn("Skill.watchDirectory")(function* ( - directory: string, - ) { - const target = path.resolve(directory) - const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (resolved) { - yield* watch(resolved, "directory") - if (resolved !== target) { - yield* watch(target, "file") - } - return resolved === target ? [target] : [target, resolved] - } - const missing = yield* firstMissing(target) - if (missing) yield* watch(missing, "file") - if ( - yield* fs.realPath(directory).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - ) { - if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`) - return yield* watchDirectory(directory) - } - return [target] - }) const state = State.create({ name: "skill", - initial: () => ({ sources: [] }), + initial: () => ({ skills: new Map() }), draft: (draft) => ({ - source: (source) => { - if (draft.sources.some((item) => Source.equals(item, source))) return - draft.sources.push(source as Types.DeepMutable) + list: () => Array.from(draft.skills.values()), + add: (skill) => { + draft.skills.set(skill.id, { ...skill } as Types.DeepMutable) + }, + update: (id, update) => { + const current = draft.skills.get(ID.make(id)) + if (!current) return + update(current) + current.id = ID.make(id) + }, + remove: (id) => { + draft.skills.delete(ID.make(id)) }, - list: () => draft.sources as Source[], }), - finalize: () => - lock - .withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid)) - .pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid), - }) - - const load = Effect.fn("Skill.load")(function* (source: Source) { - const skills: Info[] = [] - if (source.type === "embedded") { - yield* Effect.logDebug("skill source loaded", { - source: Source.key(source), - type: source.type, - directories: [], - skills: [source.skill.id], - }) - return { skills: [source.skill], paths: [] } - } - const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) - const roots = (yield* Effect.forEach(directories, watchDirectory)).flat() - const paths = [...roots] - for (const directory of directories) { - const files = yield* fs - .scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - for (const filepath of files.toSorted()) { - const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath))) - if (!roots.some((root) => FSUtil.contains(root, resolved))) { - const external = path.dirname(resolved) - paths.push(external) - yield* watch(external, "directory") - } - const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!content) continue - const markdown = ConfigMarkdown.parseOption(content) - if (!markdown) continue - const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined - if (!frontmatter) continue - const id = - path.dirname(filepath) === directory - ? path.basename(filepath, ".md") - : path.basename(path.dirname(filepath)) - skills.push({ - id: ID.make(id), - name: Name.make(frontmatter.name ?? id), - description: frontmatter.description, - slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash, - autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"), - location: AbsolutePath.make(filepath), - content: markdown.content, - }) - } - } - yield* Effect.logDebug("skill source loaded", { - source: Source.key(source), - type: source.type, - directories, - skills: skills.map((skill) => skill.id), - }) - return { skills, paths } - }) - - const list = Effect.fn("Skill.list")(function* () { - return yield* lock.withPermit( - Effect.gen(function* () { - const skills = new Map() - for (const source of state.get().sources) { - const key = Source.key(source) - const loaded = cache.get(key) ?? (yield* load(source)) - cache.set(key, loaded) - for (const skill of loaded.skills) skills.set(skill.id, skill) - } - return Array.from(skills.values()) - }), - ) + finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid), }) return Service.of({ transform: state.transform, reload: state.reload, - sources: Effect.fn("Skill.sources")(function* () { - return state.get().sources + list: Effect.fn("Skill.list")(function* () { + return Array.from(state.get().skills.values()) }), - list, }) }), ) @@ -277,5 +108,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node], + deps: [Bus.node], }) diff --git a/packages/core/test/config/fixture/skills/first-source/first/SKILL.md b/packages/core/test/config/fixture/skills/first-source/first/SKILL.md new file mode 100644 index 000000000000..c4079775b2b5 --- /dev/null +++ b/packages/core/test/config/fixture/skills/first-source/first/SKILL.md @@ -0,0 +1,5 @@ +--- +name: first +description: First skill +--- +# first diff --git a/packages/core/test/config/fixture/skills/second-source/second/SKILL.md b/packages/core/test/config/fixture/skills/second-source/second/SKILL.md new file mode 100644 index 000000000000..31236b95323c --- /dev/null +++ b/packages/core/test/config/fixture/skills/second-source/second/SKILL.md @@ -0,0 +1,5 @@ +--- +name: second +description: Second skill +--- +# second diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 85eb07911133..aa18b3408ba5 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -46,9 +46,7 @@ describe("config plugin reloads", () => { expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent") expect((yield* commands.get("first"))?.description).toBe("First command") - expect( - (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), - ).toBe(true) + expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true) expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined() @@ -69,12 +67,8 @@ describe("config plugin reloads", () => { }), ) - expect( - (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), - ).toBe(false) - expect( - (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"), - ).toBe(true) + expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false) + expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true) }).pipe( Effect.provide(Config.testLayer([config("first")])), Effect.provideService(Global.Service, Global.Service.of(Global.make())), @@ -89,7 +83,7 @@ function config(name: string) { info: decode({ agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } }, commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } }, - skills: [`/skills/${name}`], + skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)], references: { [name]: `/references/${name}` }, providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, }), diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 791eba80fa0c..99e10596df58 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -1,87 +1,216 @@ +import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema, Stream } from "effect" +import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Config } from "@opencode-ai/core/config" -import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config" +import { Document, 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 { 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 { tmpdir } from "../fixture/tmpdir" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" -const it = testEffect(Layer.empty) +const urls = new Map() +let pulls = 0 +const discoveryLayer = Layer.succeed( + SkillDiscovery.Service, + SkillDiscovery.Service.of({ + pull: (url) => { + pulls++ + 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) -describe("ConfigSkillPlugin.Plugin", () => { - it.effect("registers configured skill directories and URLs", () => - Effect.gen(function* () { - const directory = AbsolutePath.make("/repo/packages/app") - const sources: Skill.Source[] = [] - const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect) { - const result = update({ - source: (source) => { - sources.push(source) - }, - list: () => sources, - }) - if (Effect.isEffect(result)) yield* result - const dispose = Effect.sync(() => { - sources.length = 0 - }) - yield* Effect.addFinalizer(() => dispose) - return { dispose } - }) +function write(directory: string, name: string, description: string) { + return fs.writeFile( + path.join(directory, name, "SKILL.md"), + `--- +name: ${name} +description: ${description} +--- +# ${name}`, + ) +} - yield* ConfigSkillPlugin.Plugin.effect( - host({ - skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void }, - }), - ).pipe( - Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), - Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), - Effect.provide( - Config.testLayer([ - new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }), - new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }), - new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), - new Document({ - type: "document", - info: decode({ - skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"], - }), - }), - ]), +const configure = (skills: string[]) => + Config.testLayer([ + new Document({ + type: "document", + info: decode({ skills }), + }), + ]) + +const start = Effect.fnUntraced(function* (skills: string[], directory: string) { + 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(configure(skills)), + Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: directory })), + Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ) + return service +}) + +function emitAndWait(update: Watcher.Update) { + return Effect.gen(function* () { + const watcher = yield* Watcher.Test + const bus = yield* Bus.Service + const deferred = yield* Deferred.make() + 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({ + 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")?.id).toBe( + Skill.ID.make("foo"), ) + }), + ) +}) - expect(sources).toEqual([ - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join("/repo/.claude", "skills")), - }), - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join("/repo/.agents", "skills")), - }), - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), +describe("ConfigSkillPlugin.Plugin", () => { + 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) }), - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join("/repo/.opencode", "skills")), + ), + ), + ) + + 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"), + ]) }), - Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), - Skill.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join("/home/test", "shared-skills")), + ), + ), + ) + + 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")]) }), - Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }), - Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }), - ]) - }), + ), + ), ) }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index f9478c8b7f0c..4b3996b3b89d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -19,9 +19,11 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { Reference } from "@opencode-ai/core/reference" import { Skill } from "@opencode-ai/core/skill" +import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Tool } from "@opencode-ai/core/tool" import { WebSearch } from "@opencode-ai/core/websearch" -import { Effect, Layer, Stream } from "effect" +import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" const npmLayer = Layer.succeed( @@ -53,8 +55,10 @@ export const PluginTestLayer = AppNodeBuilder.build( PluginHooks.node, Reference.node, Skill.node, + SkillDiscovery.node, PluginHooks.node, Tool.node, + Watcher.node, WebSearch.node, ]), [ diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 0a05817f1dfc..c4f5cbc18328 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -1,438 +1,102 @@ -import fs from "fs/promises" -import path from "path" import { describe, expect } from "bun:test" -import { Deferred, Effect, Fiber, Layer, Stream } from "effect" +import { Deferred, Effect, Fiber, Stream } from "effect" import { Agent } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { AbsolutePath } from "@opencode-ai/core/schema" import { Skill } from "@opencode-ai/core/skill" -import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" -import { Watcher } from "@opencode-ai/core/filesystem/watcher" -import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const urls = new Map() -let pulls = 0 -const discovery = Layer.succeed( - SkillDiscovery.Service, - SkillDiscovery.Service.of({ - pull: (url) => { - pulls++ - return Effect.succeed(urls.get(url) ?? []) - }, - }), -) -const watcherLayer = Watcher.testLayer -const it = testEffect( - Layer.mergeAll( - AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [ - [SkillDiscovery.node, discovery], - [Watcher.node, watcherLayer], - ]), - watcherLayer, - ), -) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]))) -function write(directory: string, name: string, description: string) { - return fs.writeFile( - path.join(directory, name, "SKILL.md"), - `--- -name: ${name} -description: ${description} ---- -# ${name}`, - ) -} - -function waitForSkillUpdate() { - return Effect.gen(function* () { - const bus = yield* Bus.Service - const deferred = yield* Deferred.make() - const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe( - Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)), - Effect.forkScoped, - ) - yield* Effect.yieldNow - return { deferred, fiber } - }) -} - -function expectSubscription(check: (input: Watcher.WatchInput) => boolean) { - return Effect.gen(function* () { - const watcher = yield* Watcher.Test - expect((yield* watcher.subscriptions()).some(check)).toBe(true) +const info = (id: string, description: string) => + Skill.Info.make({ + id: Skill.ID.make(id), + name: Skill.Name.make(id), + description, + location: AbsolutePath.make(`/skills/${id}/SKILL.md`), + content: `# ${id}`, }) -} - -function emitAndWait(update: Watcher.Update) { - return Effect.gen(function* () { - const watcher = yield* Watcher.Test - yield* Effect.acquireUseRelease( - waitForSkillUpdate(), - ({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), - ({ fiber }) => Fiber.interrupt(fiber), - ) - }) -} describe("Skill", () => { - it.live("publishes updates when skill sources change", () => + it.effect("registers values with last-write-wins precedence", () => Effect.gen(function* () { const skill = yield* Skill.Service - - yield* Effect.acquireUseRelease( - waitForSkillUpdate(), - ({ deferred }) => - skill - .transform((editor) => - editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }), - ) - .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), - ({ fiber }) => Fiber.interrupt(fiber), - ) + yield* skill.transform((draft) => { + draft.add(info("review", "First")) + draft.add(info("deploy", "Deploy")) + draft.add(info("review", "Second")) + expect(draft.list().map((item) => item.id)).toEqual([Skill.ID.make("review"), Skill.ID.make("deploy")]) + }) + + expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")]) }), ) - it.live("registers sources and resolves 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") - await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo") - }) - - const skill = yield* Skill.Service - const watcher = yield* Watcher.Test - yield* skill.transform((editor) => { - editor.source({ type: "directory", path: AbsolutePath.make(first) }) - editor.source({ type: "directory", path: AbsolutePath.make(first) }) - editor.source({ type: "directory", path: AbsolutePath.make(second) }) - expect(editor.list()).toEqual([ - { type: "directory", path: AbsolutePath.make(first) }, - { type: "directory", path: AbsolutePath.make(second) }, - ]) - }) - - expect(yield* skill.sources()).toEqual([ - { type: "directory", path: AbsolutePath.make(first) }, - { type: "directory", path: AbsolutePath.make(second) }, - ]) - expect(yield* skill.list()).toEqual([ - Skill.Info.make({ - id: Skill.ID.make("foo"), - name: Skill.Name.make("foo"), - slash: true, - location: AbsolutePath.make(path.join(first, "foo.md")), - content: "# foo", - }), - { - id: Skill.ID.make("review"), - name: Skill.Name.make("review"), - description: "Second", - location: AbsolutePath.make(path.join(second, "review", "SKILL.md")), - content: "# review", - }, - ]) - expect(yield* watcher.subscriptions()).toEqual([ - { path: first, type: "directory" }, - { path: second, type: "directory" }, - ]) - - yield* Effect.promise(() => write(second, "review", "Updated Second")) - yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") }) - - expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second") - expect(yield* watcher.subscriptions()).toEqual([ - { path: first, type: "directory" }, - { path: second, type: "directory" }, - { path: first, type: "directory" }, - { path: second, type: "directory" }, - ]) - }), - ), - ), - ) - - it.live("loads URL sources and filters skills for agents", () => - 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", "Deploy production") - }) - pulls = 0 - urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)]) - - const agents = yield* Agent.Service - yield* agents.transform((editor) => - editor.update(Agent.ID.make("reviewer"), (agent) => { - agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) - }), - ) - - const skill = yield* Skill.Service - yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) - - expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")]) - expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")]) - expect(pulls).toBe(1) - expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([]) - }), - ), - ), - ) - - it.live("parses opencode metadata flags from skill frontmatter", () => - 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, "manual"), { recursive: true }) - await fs.writeFile( - path.join(tmp.path, "manual", "SKILL.md"), - `--- -name: manual -description: Manual only -metadata: - opencode/slash: true - opencode/autoinvoke: false ---- -# manual`, - ) - }) - - const skill = yield* Skill.Service - yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) - - expect(yield* skill.list()).toEqual([ - { - id: Skill.ID.make("manual"), - name: Skill.Name.make("manual"), - description: "Manual only", - slash: true, - autoinvoke: false, - location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")), - content: "# manual", - }, - ]) - }), - ), - ), - ) - - it.live("clears cached skills when sources reload", () => - 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 deploy") - }) - - const skill = yield* Skill.Service - const watcher = yield* Watcher.Test - const bus = yield* Bus.Service - yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) - expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy") - expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }]) - - let refreshed: Skill.Info[] = [] - const unsubscribe = yield* bus.listen((event) => { - if (event.type !== Skill.Event.Updated.type) return Effect.void - return skill.list().pipe( - Effect.tap((items) => Effect.sync(() => (refreshed = items))), - Effect.asVoid, - ) - }) - - yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy")) - yield* skill.reload().pipe(Effect.timeout("1 second")) - yield* unsubscribe - - expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy") - expect(yield* watcher.subscriptions()).toEqual([ - { path: tmp.path, type: "directory" }, - { path: tmp.path, type: "directory" }, - ]) - }), - ), - ), + it.effect("updates and removes registered values", () => + Effect.gen(function* () { + const skill = yield* Skill.Service + yield* skill.transform((draft) => { + draft.add(info("review", "Initial")) + draft.update("review", (value) => { + value.description = "Updated" + value.id = Skill.ID.make("ignored") + }) + draft.update("missing", () => Effect.die("unreachable")) + draft.add(info("deploy", "Deploy")) + draft.remove("deploy") + }) + + expect(yield* skill.list()).toEqual([info("review", "Updated")]) + }), ) - it.live("reloads project sources created after their missing parent", () => - 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 file = path.join(source, "deploy", "SKILL.md") - const skill = yield* Skill.Service - const watcher = yield* Watcher.Test - yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) })) - 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") }) - expect(yield* skill.list()).toEqual([]) - expect(yield* watcher.subscriptions()).toEqual([ - { path: path.join(tmp.path, "generated"), type: "file" }, - { path: source, type: "file" }, - ]) - - yield* Effect.promise(async () => { - await fs.mkdir(path.dirname(file), { recursive: true }) - await write(source, "deploy", "Deploy production") - }) - yield* emitAndWait({ type: "create", path: source }) - - expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")]) - expect(yield* watcher.subscriptions()).toEqual([ - { path: path.join(tmp.path, "generated"), type: "file" }, - { path: source, type: "file" }, - { path: source, type: "directory" }, - ]) + it.effect("restores earlier values when an updating transform is disposed", () => + Effect.gen(function* () { + const skill = yield* Skill.Service + const original = info("review", "Initial") + yield* skill.transform((draft) => draft.add(original)) + const updated = yield* skill.transform((draft) => + draft.update("review", (value) => { + value.description = "Updated" }), - ), - ), - ) - - it.live("watches directory sources for added and changed skills", () => - 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 deploy") - }) - - const skill = yield* Skill.Service - yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) - expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")]) - yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path) - - const deploy = path.join(tmp.path, "deploy", "SKILL.md") - yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy")) - yield* emitAndWait({ type: "update", path: deploy }) - expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy") - - yield* Effect.promise(async () => { - await fs.mkdir(path.join(tmp.path, "review"), { recursive: true }) - await write(tmp.path, "review", "Review changes") - }) - const review = path.join(tmp.path, "review", "SKILL.md") - yield* emitAndWait({ type: "create", path: review }) - 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: review }) - expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")]) - }), - ), - ), + expect((yield* skill.list())[0]?.description).toBe("Updated") + yield* updated.dispose + expect((yield* skill.list())[0]?.description).toBe("Initial") + expect(original.description).toBe("Initial") + }), ) - 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")) - }) - - const skill = yield* Skill.Service - yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) })) - expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial") - yield* expectSubscription((input) => input.type === "directory" && input.path === target) + it.live("publishes updates after committed values are visible", () => + Effect.gen(function* () { + const skill = yield* Skill.Service + const bus = yield* Bus.Service + const updated = yield* Deferred.make() + const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe( + Stream.runForEach(() => skill.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))), + Effect.forkScoped, + ) + yield* Effect.yieldNow - 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") - }), - ), - ), + yield* skill.transform((draft) => draft.add(info("review", "Visible"))) + expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([info("review", "Visible")]) + yield* Fiber.interrupt(fiber) + }), ) - it.live("invalidates 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) - }) - - const skill = yield* Skill.Service - const watcher = yield* Watcher.Test - yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) })) - 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) - }) - 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.effect("filters values by agent permissions", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + yield* agents.transform((draft) => + draft.update(Agent.ID.make("reviewer"), (agent) => { + agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) }), - ), - ), + ) + const agent = yield* agents.get(Agent.ID.make("reviewer")) + expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([]) + }), ) }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 999c23a3c524..7872d0fef1cb 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -81,7 +81,6 @@ describe("SkillTool", () => { Skill.Service.of({ transform: (_transform) => Effect.die("unused"), reload: () => Effect.die("unused"), - sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), ) diff --git a/packages/plugin/src/effect/skill.ts b/packages/plugin/src/effect/skill.ts index 26439bcfc918..cc272e015c38 100644 --- a/packages/plugin/src/effect/skill.ts +++ b/packages/plugin/src/effect/skill.ts @@ -1,11 +1,13 @@ import type { SkillApi } from "@opencode-ai/client/effect/api" import { Skill } from "@opencode-ai/schema/skill" -import type { Effect } from "effect" +import type { Effect, Types } from "effect" import type { Transform } from "./registration.js" export interface SkillDraft { - source(source: Skill.Source): void - list(): readonly Skill.Source[] + list(): readonly Types.DeepMutable[] + add(skill: Skill.Info): void + update(id: string, update: (skill: Types.DeepMutable) => void): void + remove(id: string): void } export interface SkillDomain extends SkillApi { diff --git a/packages/plugin/src/promise/skill.ts b/packages/plugin/src/promise/skill.ts index e237bc788ffc..4cfc8833a0c0 100644 --- a/packages/plugin/src/promise/skill.ts +++ b/packages/plugin/src/promise/skill.ts @@ -1,10 +1,13 @@ import type { SkillApi } from "@opencode-ai/client/promise/api" import type { Skill } from "@opencode-ai/schema/skill" import type { Transform } from "./registration.js" +import type { DeepMutable } from "./types.js" export interface SkillDraft { - source(source: Skill.Source): void - list(): readonly Skill.Source[] + list(): readonly DeepMutable[] + add(skill: Skill.Info): void + update(id: string, update: (skill: DeepMutable) => void): void + remove(id: string): void } export interface SkillDomain extends SkillApi { From c35f3a60886c10d8bcc4a7d055419a4e8275a469 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 19:48:08 -0400 Subject: [PATCH 2/3] fix(core): isolate skill source failures --- packages/core/src/config/plugin/skill-file.ts | 35 +++++++----- packages/core/src/config/plugin/skill.ts | 24 +++++++- packages/core/test/config/skill.test.ts | 56 +++++++++++++++---- 3 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/core/src/config/plugin/skill-file.ts b/packages/core/src/config/plugin/skill-file.ts index 578e978dfe32..e5fa1ac20b75 100644 --- a/packages/core/src/config/plugin/skill-file.ts +++ b/packages/core/src/config/plugin/skill-file.ts @@ -1,7 +1,7 @@ export * as SkillFile from "./skill-file" import path from "path" -import { Schema } from "effect" +import { Result, Schema, type SchemaIssue, SchemaParser } from "effect" import { ConfigMarkdown } from "../markdown" import { AbsolutePath } from "../../schema" import { Skill } from "../../skill" @@ -12,7 +12,12 @@ const Frontmatter = Schema.Struct({ slash: Schema.Boolean.pipe(Schema.optional), metadata: Schema.Unknown.pipe(Schema.optional), }) -const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) +const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter) + +export type ParseResult = + | { readonly _tag: "Parsed"; readonly skill: Skill.Info } + | { readonly _tag: "Skipped"; readonly reason: "markdown" } + | { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue } const metadataBoolean = (metadata: unknown, key: string) => { if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { @@ -27,22 +32,26 @@ const metadataBoolean = (metadata: unknown, key: string) => { return undefined } -export function parse(directory: string, filepath: string, content: string): Skill.Info | undefined { +export function parse(directory: string, filepath: string, content: string): ParseResult { const markdown = ConfigMarkdown.parseOption(content) - if (!markdown) return undefined - const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined - if (!frontmatter) return undefined + if (!markdown) return { _tag: "Skipped", reason: "markdown" } + const decoded = decodeFrontmatter(markdown.data) + if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure } + const frontmatter = decoded.success const id = path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath)) const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke") return { - id: Skill.ID.make(id), - name: Skill.Name.make(frontmatter.name ?? id), - ...(frontmatter.description === undefined ? {} : { description: frontmatter.description }), - ...(slash === undefined ? {} : { slash }), - ...(autoinvoke === undefined ? {} : { autoinvoke }), - location: AbsolutePath.make(filepath), - content: markdown.content, + _tag: "Parsed", + skill: { + id: Skill.ID.make(id), + name: Skill.Name.make(frontmatter.name ?? id), + ...(frontmatter.description === undefined ? {} : { description: frontmatter.description }), + ...(slash === undefined ? {} : { slash }), + ...(autoinvoke === undefined ? {} : { autoinvoke }), + location: AbsolutePath.make(filepath), + content: markdown.content, + }, } } diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 5279e5d023bf..a7b49af00bfb 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -108,7 +108,17 @@ export const Plugin = define({ } const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) { - const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) + const directories = + source.type === "directory" + ? [source.path] + : yield* discovery.pull(source.url).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to load skill source", { + source: Skill.Source.key(source), + cause, + }).pipe(Effect.as([] as AbsolutePath[])), + ), + ) const roots = (yield* Effect.forEach(directories, watchDirectory)).flat() const skills: Skill.Info[] = [] for (const directory of directories) { @@ -120,8 +130,16 @@ export const Plugin = define({ if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory") const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!content) continue - const skill = SkillFile.parse(directory, filepath, content) - if (skill) skills.push(skill) + const parsed = SkillFile.parse(directory, filepath, content) + if (parsed._tag === "Skipped") { + yield* Effect.logDebug("skill file skipped", { + filepath, + reason: parsed.reason, + ...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}), + }) + continue + } + skills.push(parsed.skill) } } yield* Effect.logDebug("skill source loaded", { diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 99e10596df58..8dfe126d380e 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -22,12 +22,14 @@ import { testEffect } from "../lib/effect" import { host } from "../plugin/host" const urls = new Map() +const failedUrls = new Set() 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) ?? []) }, }), @@ -113,17 +115,29 @@ metadata: # manual`, ), ).toEqual({ - 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", + _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(), }) - expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")?.id).toBe( - Skill.ID.make("foo"), - ) }), ) }) @@ -155,6 +169,28 @@ 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.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()), From c763eec6a9f23f80ad8e3812e9c97c20527212ef Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 20:07:21 -0400 Subject: [PATCH 3/3] test(core): cover config skill source watching --- packages/core/test/config/skill.test.ts | 151 ++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 12 deletions(-) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 8dfe126d380e..fc2c1b847ea4 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -3,7 +3,14 @@ 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 { Document, Info } from "@opencode-ai/schema/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" @@ -55,15 +62,7 @@ description: ${description} ) } -const configure = (skills: string[]) => - Config.testLayer([ - new Document({ - type: "document", - info: decode({ skills }), - }), - ]) - -const start = Effect.fnUntraced(function* (skills: string[], directory: string) { +const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) { const service = yield* Skill.Service yield* ConfigSkillPlugin.Plugin.effect( host({ @@ -74,13 +73,24 @@ const start = Effect.fnUntraced(function* (skills: string[], directory: string) }, }), ).pipe( - Effect.provide(configure(skills)), - Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: directory })), + 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, + ) + function emitAndWait(update: Watcher.Update) { return Effect.gen(function* () { const watcher = yield* Watcher.Test @@ -143,6 +153,45 @@ metadata: }) 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()), @@ -219,6 +268,84 @@ describe("ConfigSkillPlugin.Plugin", () => { 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" }, + ]) }), ), ),