import os from "os" import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, ServiceMap } from "effect" import { NamedError } from "@opencode-ai/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceContext } from "@/effect/instance-context" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission/service" import { Filesystem } from "@/util/filesystem" import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { Glob } from "../util/glob" import { Log } from "../util/log" import { Discovery } from "./discovery" export namespace Skill { const log = Log.create({ service: "skill" }) 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 Info = z.object({ name: z.string(), description: z.string(), location: z.string(), content: z.string(), }) export type Info = z.infer export const InvalidError = NamedError.create( "SkillInvalidError", z.object({ path: z.string(), message: z.string().optional(), issues: z.custom().optional(), }), ) export const NameMismatchError = NamedError.create( "SkillNameMismatchError", z.object({ path: z.string(), expected: z.string(), actual: z.string(), }), ) type State = { skills: Record dirs: Set task?: Promise } type Cache = State & { ensure: () => Promise } export interface Interface { readonly get: (name: string) => Effect.Effect readonly all: () => Effect.Effect readonly dirs: () => Effect.Effect readonly available: (agent?: Agent.Info) => Effect.Effect } const add = async (state: State, 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 = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) return if (state.skills[parsed.data.name]) { log.warn("duplicate skill name", { name: parsed.data.name, existing: state.skills[parsed.data.name].location, duplicate: match, }) } state.dirs.add(path.dirname(match)) state.skills[parsed.data.name] = { name: parsed.data.name, description: parsed.data.description, location: match, content: md.content, } } const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { return Glob.scan(pattern, { cwd: root, absolute: true, include: "file", symlink: true, dot: opts?.dot, }) .then((matches) => Promise.all(matches.map((match) => add(state, match)))) .catch((error) => { if (!opts?.scope) throw error log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) }) } // TODO: Migrate to Effect const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { const state: State = { skills: {}, dirs: new Set(), } const load = async () => { 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 scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } for await (const root of Filesystem.up({ targets: EXTERNAL_DIRS, start: instance.directory, stop: instance.project.worktree, })) { await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) } } for (const dir of await Config.directories()) { await scan(state, dir, OPENCODE_SKILL_PATTERN) } const cfg = await Config.get() for (const item of cfg.skills?.paths ?? []) { const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) if (!(await Filesystem.isDir(dir))) { log.warn("skill path not found", { path: dir }) continue } await scan(state, dir, SKILL_PATTERN) } for (const url of cfg.skills?.urls ?? []) { for (const dir of await Effect.runPromise(discovery.pull(url))) { state.dirs.add(dir) await scan(state, dir, SKILL_PATTERN) } } log.info("init", { count: Object.keys(state.skills).length }) } const ensure = () => { if (state.task) return state.task state.task = load().catch((err) => { state.task = undefined throw err }) return state.task } return { ...state, ensure } } export class Service extends ServiceMap.Service()("@opencode/Skill") {} export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const instance = yield* InstanceContext const discovery = yield* Discovery.Service const state = create(instance, discovery) const get = Effect.fn("Skill.get")(function* (name: string) { yield* Effect.promise(() => state.ensure()) return state.skills[name] }) const all = Effect.fn("Skill.all")(function* () { yield* Effect.promise(() => state.ensure()) return Object.values(state.skills) }) const dirs = Effect.fn("Skill.dirs")(function* () { yield* Effect.promise(() => state.ensure()) return Array.from(state.dirs) }) const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { yield* Effect.promise(() => state.ensure()) const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) if (!agent) return list return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") }) return Service.of({ get, all, dirs, available }) }), ).pipe(Layer.fresh) export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Discovery.defaultLayer), ) export function fmt(list: Info[], opts: { verbose: boolean }) { if (list.length === 0) return "No skills are currently available." if (opts.verbose) { return [ "", ...list.flatMap((skill) => [ " ", ` ${skill.name}`, ` ${skill.description}`, ` ${pathToFileURL(skill.location).href}`, " ", ]), "", ].join("\n") } return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") } }