2
0

skill.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import z from "zod"
  2. import path from "path"
  3. import os from "os"
  4. import { Config } from "../config/config"
  5. import { Instance } from "../project/instance"
  6. import { NamedError } from "@opencode-ai/util/error"
  7. import { ConfigMarkdown } from "../config/markdown"
  8. import { Log } from "../util/log"
  9. import { Global } from "@/global"
  10. import { Filesystem } from "@/util/filesystem"
  11. import { Flag } from "@/flag/flag"
  12. import { Bus } from "@/bus"
  13. import { Session } from "@/session"
  14. import { Discovery } from "./discovery"
  15. export namespace Skill {
  16. const log = Log.create({ service: "skill" })
  17. export const Info = z.object({
  18. name: z.string(),
  19. description: z.string(),
  20. location: z.string(),
  21. content: z.string(),
  22. })
  23. export type Info = z.infer<typeof Info>
  24. export const InvalidError = NamedError.create(
  25. "SkillInvalidError",
  26. z.object({
  27. path: z.string(),
  28. message: z.string().optional(),
  29. issues: z.custom<z.core.$ZodIssue[]>().optional(),
  30. }),
  31. )
  32. export const NameMismatchError = NamedError.create(
  33. "SkillNameMismatchError",
  34. z.object({
  35. path: z.string(),
  36. expected: z.string(),
  37. actual: z.string(),
  38. }),
  39. )
  40. // External skill directories to search for (project-level and global)
  41. // These follow the directory layout used by Claude Code and other agents.
  42. const EXTERNAL_DIRS = [".claude", ".agents"]
  43. const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
  44. const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
  45. const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
  46. export const state = Instance.state(async () => {
  47. const skills: Record<string, Info> = {}
  48. const dirs = new Set<string>()
  49. const addSkill = async (match: string) => {
  50. const md = await ConfigMarkdown.parse(match).catch((err) => {
  51. const message = ConfigMarkdown.FrontmatterError.isInstance(err)
  52. ? err.data.message
  53. : `Failed to parse skill ${match}`
  54. Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
  55. log.error("failed to load skill", { skill: match, err })
  56. return undefined
  57. })
  58. if (!md) return
  59. const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
  60. if (!parsed.success) return
  61. // Warn on duplicate skill names
  62. if (skills[parsed.data.name]) {
  63. log.warn("duplicate skill name", {
  64. name: parsed.data.name,
  65. existing: skills[parsed.data.name].location,
  66. duplicate: match,
  67. })
  68. }
  69. dirs.add(path.dirname(match))
  70. skills[parsed.data.name] = {
  71. name: parsed.data.name,
  72. description: parsed.data.description,
  73. location: match,
  74. content: md.content,
  75. }
  76. }
  77. const scanExternal = async (root: string, scope: "global" | "project") => {
  78. return Array.fromAsync(
  79. EXTERNAL_SKILL_GLOB.scan({
  80. cwd: root,
  81. absolute: true,
  82. onlyFiles: true,
  83. followSymlinks: true,
  84. dot: true,
  85. }),
  86. )
  87. .then((matches) => Promise.all(matches.map(addSkill)))
  88. .catch((error) => {
  89. log.error(`failed to scan ${scope} skills`, { dir: root, error })
  90. })
  91. }
  92. // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
  93. // Load global (home) first, then project-level (so project-level overwrites)
  94. if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
  95. for (const dir of EXTERNAL_DIRS) {
  96. const root = path.join(Global.Path.home, dir)
  97. if (!(await Filesystem.isDir(root))) continue
  98. await scanExternal(root, "global")
  99. }
  100. for await (const root of Filesystem.up({
  101. targets: EXTERNAL_DIRS,
  102. start: Instance.directory,
  103. stop: Instance.worktree,
  104. })) {
  105. await scanExternal(root, "project")
  106. }
  107. }
  108. // Scan .opencode/skill/ directories
  109. for (const dir of await Config.directories()) {
  110. for await (const match of OPENCODE_SKILL_GLOB.scan({
  111. cwd: dir,
  112. absolute: true,
  113. onlyFiles: true,
  114. followSymlinks: true,
  115. })) {
  116. await addSkill(match)
  117. }
  118. }
  119. // Scan additional skill paths from config
  120. const config = await Config.get()
  121. for (const skillPath of config.skills?.paths ?? []) {
  122. const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
  123. const resolved = path.isAbsolute(expanded) ? expanded : path.join(Instance.directory, expanded)
  124. if (!(await Filesystem.isDir(resolved))) {
  125. log.warn("skill path not found", { path: resolved })
  126. continue
  127. }
  128. for await (const match of SKILL_GLOB.scan({
  129. cwd: resolved,
  130. absolute: true,
  131. onlyFiles: true,
  132. followSymlinks: true,
  133. })) {
  134. await addSkill(match)
  135. }
  136. }
  137. // Download and load skills from URLs
  138. for (const url of config.skills?.urls ?? []) {
  139. const list = await Discovery.pull(url)
  140. for (const dir of list) {
  141. dirs.add(dir)
  142. for await (const match of SKILL_GLOB.scan({
  143. cwd: dir,
  144. absolute: true,
  145. onlyFiles: true,
  146. followSymlinks: true,
  147. })) {
  148. await addSkill(match)
  149. }
  150. }
  151. }
  152. return {
  153. skills,
  154. dirs: Array.from(dirs),
  155. }
  156. })
  157. export async function get(name: string) {
  158. return state().then((x) => x.skills[name])
  159. }
  160. export async function all() {
  161. return state().then((x) => Object.values(x.skills))
  162. }
  163. export async function dirs() {
  164. return state().then((x) => x.dirs)
  165. }
  166. }