mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
195 lines
8.0 KiB
TypeScript
195 lines
8.0 KiB
TypeScript
export * as ConfigSkillPlugin from "./skill.js"
|
|
|
|
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, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
|
import { Config } from "../../config.js"
|
|
import { Watcher } from "../../filesystem/watcher.js"
|
|
import { Location } from "../../location.js"
|
|
import { AbsolutePath } from "../../schema.js"
|
|
import { Skill } from "../../skill.js"
|
|
import { SkillDiscovery } from "../../skill/discovery.js"
|
|
import { SkillFile } from "./skill-file.js"
|
|
|
|
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 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]) {
|
|
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
|
}
|
|
for (const directory of directories) {
|
|
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)) {
|
|
add(Skill.UrlSource.make({ type: "url", url: item }))
|
|
continue
|
|
}
|
|
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
|
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("config.updated").pipe(
|
|
Stream.runForEach(() =>
|
|
config.entries().pipe(
|
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
|
Effect.andThen(refresh()),
|
|
Effect.andThen(ctx.skill.reload()),
|
|
),
|
|
),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
}),
|
|
})
|