| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- import z from "zod"
- import path from "path"
- import os from "os"
- import { Config } from "../config/config"
- import { Instance } from "../project/instance"
- import { NamedError } from "@opencode-ai/util/error"
- import { ConfigMarkdown } from "../config/markdown"
- import { Log } from "../util/log"
- 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"
- export namespace Skill {
- const log = Log.create({ service: "skill" })
- export const Info = z.object({
- name: z.string(),
- description: z.string(),
- location: z.string(),
- content: z.string(),
- })
- export type Info = z.infer<typeof Info>
- export const InvalidError = NamedError.create(
- "SkillInvalidError",
- z.object({
- path: z.string(),
- message: z.string().optional(),
- issues: z.custom<z.core.$ZodIssue[]>().optional(),
- }),
- )
- export const NameMismatchError = NamedError.create(
- "SkillNameMismatchError",
- z.object({
- path: z.string(),
- expected: z.string(),
- actual: z.string(),
- }),
- )
- // 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_GLOB = new Bun.Glob("skills/**/SKILL.md")
- const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
- const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
- export const state = Instance.state(async () => {
- const skills: Record<string, Info> = {}
- const dirs = new Set<string>()
- 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 Array.fromAsync(
- EXTERNAL_SKILL_GLOB.scan({
- cwd: root,
- absolute: true,
- onlyFiles: true,
- followSymlinks: true,
- dot: 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()) {
- for await (const match of OPENCODE_SKILL_GLOB.scan({
- cwd: dir,
- absolute: true,
- onlyFiles: true,
- followSymlinks: true,
- })) {
- 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
- }
- for await (const match of SKILL_GLOB.scan({
- cwd: resolved,
- absolute: true,
- onlyFiles: true,
- followSymlinks: true,
- })) {
- 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)
- for await (const match of SKILL_GLOB.scan({
- cwd: dir,
- absolute: true,
- onlyFiles: true,
- followSymlinks: true,
- })) {
- await addSkill(match)
- }
- }
- }
- return {
- skills,
- dirs: Array.from(dirs),
- }
- })
- export async function get(name: string) {
- return state().then((x) => x.skills[name])
- }
- export async function all() {
- return state().then((x) => Object.values(x.skills))
- }
- export async function dirs() {
- return state().then((x) => x.dirs)
- }
- }
|