From 06f620a8bcdfd2e4450b8927e9ce7ba8f24e63ec Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 09:49:03 -0400 Subject: [PATCH] refactor(skill): effectify SkillService as scoped service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert Skill namespace to SkillService class with Effect.fn methods. Scanning runs eagerly in layer constructor — needs lazy init to avoid breaking unrelated tests (same pattern as FileService). --- packages/opencode/src/effect/instances.ts | 3 + packages/opencode/src/skill/skill.ts | 330 +++++++++++++--------- 2 files changed, 192 insertions(+), 141 deletions(-) diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 240f8ee66ab6..eabf19868862 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -9,6 +9,7 @@ import { VcsService } from "@/project/vcs" import { FileTimeService } from "@/file/time" import { FormatService } from "@/format" import { FileService } from "@/file" +import { SkillService } from "@/skill/skill" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -22,6 +23,7 @@ export type InstanceServices = | FileTimeService | FormatService | FileService + | SkillService function lookup(directory: string) { const project = Instance.project @@ -35,6 +37,7 @@ function lookup(directory: string) { Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FormatService.layer), Layer.fresh(FileService.layer), + Layer.fresh(SkillService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index fa984b3e1111..7346f702a385 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -10,15 +10,25 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Bus } from "@/bus" -import { Session } from "@/session" import { Discovery } from "./discovery" import { Glob } from "../util/glob" import { pathToFileURL } from "url" import type { Agent } from "@/agent/agent" import { PermissionNext } from "@/permission/next" +import { InstanceContext } from "@/effect/instance-context" +import { Effect, Layer, ServiceMap } from "effect" +import { runPromiseInstance } from "@/effect/runtime" + +const log = Log.create({ service: "skill" }) + +// External skill directories to search for (project-level and global) +// These follow the directory layout used by Claude Code and other agents. +const EXTERNAL_DIRS = [".claude", ".agents"] +const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" +const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" +const SKILL_PATTERN = "**/SKILL.md" export namespace Skill { - const log = Log.create({ service: "skill" }) export const Info = z.object({ name: z.string(), description: z.string(), @@ -45,155 +55,20 @@ export namespace Skill { }), ) - // External skill directories to search for (project-level and global) - // These follow the directory layout used by Claude Code and other agents. - const EXTERNAL_DIRS = [".claude", ".agents"] - const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" - const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" - const SKILL_PATTERN = "**/SKILL.md" - - export const state = Instance.state(async () => { - const skills: Record = {} - const dirs = new Set() - - const addSkill = async (match: string) => { - const md = await ConfigMarkdown.parse(match).catch((err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse skill ${match}` - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) - return undefined - }) - - if (!md) return - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - // Warn on duplicate skill names - if (skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: skills[parsed.data.name].location, - duplicate: match, - }) - } - - dirs.add(path.dirname(match)) - - skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - } - - const scanExternal = async (root: string, scope: "global" | "project") => { - return Glob.scan(EXTERNAL_SKILL_PATTERN, { - cwd: root, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .then((matches) => Promise.all(matches.map(addSkill))) - .catch((error) => { - log.error(`failed to scan ${scope} skills`, { dir: root, error }) - }) - } - - // Scan external skill directories (.claude/skills/, .agents/skills/, etc.) - // Load global (home) first, then project-level (so project-level overwrites) - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) - if (!(await Filesystem.isDir(root))) continue - await scanExternal(root, "global") - } - - for await (const root of Filesystem.up({ - targets: EXTERNAL_DIRS, - start: Instance.directory, - stop: Instance.worktree, - })) { - await scanExternal(root, "project") - } - } - - // Scan .opencode/skill/ directories - for (const dir of await Config.directories()) { - const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, { - cwd: dir, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - - // Scan additional skill paths from config - const config = await Config.get() - for (const skillPath of config.skills?.paths ?? []) { - const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath - const resolved = path.isAbsolute(expanded) ? expanded : path.join(Instance.directory, expanded) - if (!(await Filesystem.isDir(resolved))) { - log.warn("skill path not found", { path: resolved }) - continue - } - const matches = await Glob.scan(SKILL_PATTERN, { - cwd: resolved, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - - // Download and load skills from URLs - for (const url of config.skills?.urls ?? []) { - const list = await Discovery.pull(url) - for (const dir of list) { - dirs.add(dir) - const matches = await Glob.scan(SKILL_PATTERN, { - cwd: dir, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - } - - return { - skills, - dirs: Array.from(dirs), - } - }) - export async function get(name: string) { - return state().then((x) => x.skills[name]) + return runPromiseInstance(SkillService.use((s) => s.get(name))) } export async function all() { - return state().then((x) => Object.values(x.skills)) + return runPromiseInstance(SkillService.use((s) => s.all())) } export async function dirs() { - return state().then((x) => x.dirs) + return runPromiseInstance(SkillService.use((s) => s.dirs())) } export async function available(agent?: Agent.Info) { - const list = await all() - if (!agent) return list - return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") + return runPromiseInstance(SkillService.use((s) => s.available(agent))) } export function fmt(list: Info[], opts: { verbose: boolean }) { @@ -216,3 +91,176 @@ export namespace Skill { return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") } } + +export namespace SkillService { + export interface Service { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } +} + +export class SkillService extends ServiceMap.Service()("@opencode/Skill") { + static readonly layer = Layer.effect( + SkillService, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const skills: Record = {} + const skillDirs = new Set() + let task: Promise | undefined + + const addSkill = async (match: string) => { + const md = await ConfigMarkdown.parse(match).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }) + + if (!md) return + + const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + // Warn on duplicate skill names + if (skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: skills[parsed.data.name].location, + duplicate: match, + }) + } + + skillDirs.add(path.dirname(match)) + + skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + } + } + + const scanExternal = async (root: string, scope: "global" | "project") => { + return Glob.scan(EXTERNAL_SKILL_PATTERN, { + cwd: root, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .then((matches) => Promise.all(matches.map(addSkill))) + .catch((error) => { + log.error(`failed to scan ${scope} skills`, { dir: root, error }) + }) + } + + function ensureScanned() { + if (task) return task + task = (async () => { + // Scan external skill directories (.claude/skills/, .agents/skills/, etc.) + // Load global (home) first, then project-level (so project-level overwrites) + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(await Filesystem.isDir(root))) continue + await scanExternal(root, "global") + } + + for await (const root of Filesystem.up({ + targets: EXTERNAL_DIRS, + start: instance.directory, + stop: instance.project.worktree, + })) { + await scanExternal(root, "project") + } + } + + // Scan .opencode/skill/ directories + for (const dir of await Config.directories()) { + const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, { + cwd: dir, + absolute: true, + include: "file", + symlink: true, + }) + for (const match of matches) { + await addSkill(match) + } + } + + // Scan additional skill paths from config + const config = await Config.get() + for (const skillPath of config.skills?.paths ?? []) { + const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath + const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) + if (!(await Filesystem.isDir(resolved))) { + log.warn("skill path not found", { path: resolved }) + continue + } + const matches = await Glob.scan(SKILL_PATTERN, { + cwd: resolved, + absolute: true, + include: "file", + symlink: true, + }) + for (const match of matches) { + await addSkill(match) + } + } + + // Download and load skills from URLs + for (const url of config.skills?.urls ?? []) { + const list = await Discovery.pull(url) + for (const dir of list) { + skillDirs.add(dir) + const matches = await Glob.scan(SKILL_PATTERN, { + cwd: dir, + absolute: true, + include: "file", + symlink: true, + }) + for (const match of matches) { + await addSkill(match) + } + } + } + + log.info("init", { count: Object.keys(skills).length }) + })().catch((err) => { + task = undefined + throw err + }) + return task + } + + return SkillService.of({ + get: Effect.fn("SkillService.get")(function* (name: string) { + yield* Effect.promise(() => ensureScanned()) + return skills[name] + }), + all: Effect.fn("SkillService.all")(function* () { + yield* Effect.promise(() => ensureScanned()) + return Object.values(skills) + }), + dirs: Effect.fn("SkillService.dirs")(function* () { + yield* Effect.promise(() => ensureScanned()) + return Array.from(skillDirs) + }), + available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) { + yield* Effect.promise(() => ensureScanned()) + const list = Object.values(skills) + if (!agent) return list + return list.filter( + (skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny", + ) + }), + }) + }), + ) +}