Compare commits

..

4 Commits

Author SHA1 Message Date
Kit Langton b550e6818a fix(core): isolate skill source failures 2026-08-10 19:48:08 -04:00
Kit Langton 7b09aa13e7 refactor(core): skill service stores values, config plugin owns the filesystem 2026-08-10 18:51:19 -04:00
Simon Klee 283258e95b feat(tui): add clipboard image previews and transcript rendering (#41603)
Use the OpenTUI clipboard service for image input, show image previews in
the prompt, and render transcript images with interactive previews.

Note this includes upgrade of opentui to 0.5.1 +
anomalyco/opentui#1271
2026-08-10 22:51:39 +02:00
opencode-agent[bot] d7a7256bb6 test: stabilize Windows CI timing (#41600)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-10 15:25:55 -05:00
20 changed files with 618 additions and 751 deletions
@@ -11,7 +11,9 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
expect(await fixture.spawn().close()).toBe(0)
const acp = fixture.spawn()
await initialize(acp)
expect(await acp.close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
@@ -0,0 +1,57 @@
export * as SkillFile from "./skill-file"
import path from "path"
import { Result, Schema, type SchemaIssue, SchemaParser } 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 = 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)) {
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): ParseResult {
const markdown = ConfigMarkdown.parseOption(content)
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 {
_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,
},
}
}
+149 -22
View File
@@ -1,64 +1,191 @@
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<string>()
const changes = yield* PubSub.sliding<string>(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<string | undefined> {
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<string[]> = 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).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) {
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 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", {
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<Skill.ID, Skill.Info>()
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()),
),
),
+4 -2
View File
@@ -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,
})
}),
},
+6
View File
@@ -37,6 +37,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"
@@ -95,7 +97,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),
@@ -128,7 +132,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),
)
})
+15 -21
View File
@@ -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,
}),
)
})
+2
View File
@@ -37,6 +37,7 @@ import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
import { SkillDiscovery } from "../skill/discovery"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool"
import { WebSearch } from "../websearch"
@@ -352,6 +353,7 @@ export const node = makeLocationNode({
SessionInstructions.node,
Shell.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
Watcher.node,
WebSearch.node,
+23 -192
View File
@@ -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<string>) => {
].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<Source>[]
skills: Map<ID, Types.DeepMutable<Info>>
}
export type Draft = {
source: (source: Source) => void
list: () => readonly Source[]
list: () => readonly Types.DeepMutable<Info>[]
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
}
@@ -97,179 +72,35 @@ export class Service extends Context.Service<Service, Interface>()("@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<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
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<string | undefined> {
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<string[]> = 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<Data, Draft>({
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<Source>)
list: () => Array.from(draft.skills.values()),
add: (skill) => {
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
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<ID, Info>()
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],
})
+1
View File
@@ -465,6 +465,7 @@ Use native v2 fields.`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
@@ -185,6 +185,7 @@ Review files`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
@@ -0,0 +1,5 @@
---
name: first
description: First skill
---
# first
@@ -0,0 +1,5 @@
---
name: second
description: Second skill
---
# second
+4 -10
View File
@@ -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` } } } },
}),
+233 -68
View File
@@ -1,87 +1,252 @@
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<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)
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<void>) {
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<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`,
),
)
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")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
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")),
}),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
])
).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("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("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"),
])
}),
),
),
)
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")])
}),
),
),
)
})
+5 -1
View File
@@ -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,
]),
[
+71 -407
View File
@@ -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<string, AbsolutePath[]>()
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<void>()
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 }
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 expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
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* 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")])
})
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),
)
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")
})
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")
})
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" },
])
}),
),
),
expect(yield* skill.list()).toEqual([info("review", "Updated")])
}),
)
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.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"
}),
),
),
)
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("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`,
)
})
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<Skill.Info[]>()
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
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",
},
])
}),
),
),
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("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("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" })
}),
),
),
)
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.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")])
}),
),
),
)
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)
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("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" },
])
}),
),
),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
}),
)
})
+24 -21
View File
@@ -286,27 +286,30 @@ describe("ShellTool", () => {
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
it.live(
"permissions compound commands separately",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live(
-1
View File
@@ -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),
}),
)
+5 -3
View File
@@ -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<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
export interface SkillDomain extends SkillApi<unknown> {
+5 -2
View File
@@ -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<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
export interface SkillDomain extends SkillApi {