import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { Global } from "@opencode-ai/core/global" import { Permission } from "@/permission" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Config } from "@/config/config" import { ConfigMarkdown } from "@/config/markdown" import { RuntimeFlags } from "@/effect/runtime-flags" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } import { isRecord } from "@/util/record" const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" const AGENTS_EXTERNAL_DIR = ".agents" const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" // Built-in skill that ships with opencode. The model's intuition for what an // opencode.json should look like is often wrong, and opencode hard-fails on // invalid config, so users hit cryptic startup errors. Loading this skill // when the model is asked to touch opencode's own config files gives it the // actual schemas instead of guesses. const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, }) export type Info = Schema.Schema.Type const Issue = Schema.StructWithRest( Schema.Struct({ message: Schema.String, path: Schema.Array(Schema.String), }), [Schema.Record(Schema.String, Schema.Unknown)], ) function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { return ( isRecord(data) && typeof data.name === "string" && (data.description === undefined || typeof data.description === "string") ) } export const InvalidError = NamedError.create("SkillInvalidError", { path: Schema.String, message: Schema.optional(Schema.String), issues: Schema.optional(Schema.Array(Issue)), }) export const NameMismatchError = NamedError.create("SkillNameMismatchError", { path: Schema.String, expected: Schema.String, actual: Schema.String, }) type State = { skills: Record dirs: Set } type DiscoveryState = { matches: string[] dirs: string[] } type ScanState = { matches: Set dirs: Set } 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 = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) { const md = yield* Effect.tryPromise({ try: () => ConfigMarkdown.parse(match), catch: (err) => err, }).pipe( Effect.catch( Effect.fnUntraced(function* (err) { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}` const { Session } = yield* Effect.promise(() => import("@/session/session")) yield* 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 if (!isSkillFrontmatter(md.data)) return if (state.skills[md.data.name]) { log.warn("duplicate skill name", { name: md.data.name, existing: state.skills[md.data.name].location, duplicate: match, }) } state.dirs.add(path.dirname(match)) state.skills[md.data.name] = { name: md.data.name, description: md.data.description, location: match, content: md.content, } }) const scan = Effect.fnUntraced(function* ( state: ScanState, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }, ) { const matches = yield* Effect.tryPromise({ try: () => Glob.scan(pattern, { cwd: root, absolute: true, include: "file", symlink: true, dot: opts?.dot, }), catch: (error) => error, }).pipe( Effect.catch((error) => { if (!opts?.scope) return Effect.die(error) log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) return Effect.succeed([] as string[]) }), ) for (const match of matches) { state.matches.add(match) state.dirs.add(path.dirname(match)) } }) const discoverSkills = Effect.fnUntraced(function* ( config: Config.Interface, discovery: Discovery.Interface, fsys: AppFileSystem.Interface, global: Global.Interface, disableExternalSkills: boolean, disableClaudeCodeSkills: boolean, directory: string, worktree: string, ) { const state: ScanState = { matches: new Set(), dirs: new Set() } const externalDirs: string[] = [] if (!disableExternalSkills) { if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR) externalDirs.push(AGENTS_EXTERNAL_DIR) for (const dir of externalDirs) { const root = path.join(global.home, dir) if (!(yield* fsys.isDir(root))) continue yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } const upDirs = yield* fsys .up({ targets: externalDirs, start: directory, stop: worktree }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) for (const root of upDirs) { yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) } } const configDirs = yield* config.directories() for (const dir of configDirs) { yield* scan(state, dir, OPENCODE_SKILL_PATTERN) } const cfg = yield* config.get() for (const item of cfg.skills?.paths ?? []) { const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) if (!(yield* fsys.isDir(dir))) { log.warn("skill path not found", { path: dir }) continue } yield* scan(state, dir, SKILL_PATTERN) } for (const url of cfg.skills?.urls ?? []) { const pulledDirs = yield* discovery.pull(url) for (const dir of pulledDirs) { yield* scan(state, dir, SKILL_PATTERN) } } return { matches: Array.from(state.matches), dirs: Array.from(state.dirs), } }) const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) { yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), { concurrency: "unbounded", discard: true, }) log.info("init", { count: Object.keys(state.skills).length }) }) export class Service extends Context.Service()("@opencode/Skill") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const discovery = yield* Discovery.Service const config = yield* Config.Service const bus = yield* Bus.Service const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const flags = yield* RuntimeFlags.Service const discovered = yield* InstanceState.make( Effect.fn("Skill.discovery")(function* (ctx) { return yield* discoverSkills( config, discovery, fsys, global, flags.disableExternalSkills, flags.disableClaudeCodeSkills, ctx.directory, ctx.worktree, ) }), ) const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } // Register the built-in skill BEFORE disk discovery so a user-disk // skill with the same name can override it. s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { name: CUSTOMIZE_OPENCODE_SKILL_NAME, description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, location: "", content: CUSTOMIZE_OPENCODE_SKILL_BODY, } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), ) const get = Effect.fn("Skill.get")(function* (name: string) { const s = yield* InstanceState.get(state) return s.skills[name] }) const all = Effect.fn("Skill.all")(function* () { const s = yield* InstanceState.get(state) return Object.values(s.skills) }) const dirs = Effect.fn("Skill.dirs")(function* () { return (yield* InstanceState.get(discovered)).dirs }) const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { const s = yield* InstanceState.get(state) const list = Object.values(s.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 }) }), ) export const defaultLayer = layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Bus.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.defaultLayer), ) export function fmt(list: Info[], opts: { verbose: boolean }) { const described = list.filter((skill) => skill.description !== undefined) if (described.length === 0) return "No skills are currently available." if (opts.verbose) { return [ "", ...described .toSorted((a, b) => a.name.localeCompare(b.name)) .flatMap((skill) => [ " ", ` ${skill.name}`, ` ${skill.description}`, ` ${pathToFileURL(skill.location).href}`, " ", ]), "", ].join("\n") } return [ "## Available Skills", ...described .toSorted((a, b) => a.name.localeCompare(b.name)) .map((skill) => `- **${skill.name}**: ${skill.description}`), ].join("\n") } export * as Skill from "."