skill.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import z from "zod"
  2. import path from "path"
  3. import { Config } from "../config/config"
  4. import { Instance } from "../project/instance"
  5. import { NamedError } from "@opencode-ai/util/error"
  6. import { ConfigMarkdown } from "../config/markdown"
  7. import { Log } from "../util/log"
  8. import { Global } from "@/global"
  9. import { Filesystem } from "@/util/filesystem"
  10. import { Flag } from "@/flag/flag"
  11. import { Bus } from "@/bus"
  12. import { Session } from "@/session"
  13. export namespace Skill {
  14. const log = Log.create({ service: "skill" })
  15. export const Info = z.object({
  16. name: z.string(),
  17. description: z.string(),
  18. location: z.string(),
  19. })
  20. export type Info = z.infer<typeof Info>
  21. export const InvalidError = NamedError.create(
  22. "SkillInvalidError",
  23. z.object({
  24. path: z.string(),
  25. message: z.string().optional(),
  26. issues: z.custom<z.core.$ZodIssue[]>().optional(),
  27. }),
  28. )
  29. export const NameMismatchError = NamedError.create(
  30. "SkillNameMismatchError",
  31. z.object({
  32. path: z.string(),
  33. expected: z.string(),
  34. actual: z.string(),
  35. }),
  36. )
  37. const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
  38. const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
  39. export const state = Instance.state(async () => {
  40. const skills: Record<string, Info> = {}
  41. const addSkill = async (match: string) => {
  42. const md = await ConfigMarkdown.parse(match).catch((err) => {
  43. const message = ConfigMarkdown.FrontmatterError.isInstance(err)
  44. ? err.data.message
  45. : `Failed to parse skill ${match}`
  46. Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
  47. log.error("failed to load skill", { skill: match, err })
  48. return undefined
  49. })
  50. if (!md) return
  51. const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
  52. if (!parsed.success) return
  53. // Warn on duplicate skill names
  54. if (skills[parsed.data.name]) {
  55. log.warn("duplicate skill name", {
  56. name: parsed.data.name,
  57. existing: skills[parsed.data.name].location,
  58. duplicate: match,
  59. })
  60. }
  61. skills[parsed.data.name] = {
  62. name: parsed.data.name,
  63. description: parsed.data.description,
  64. location: match,
  65. }
  66. }
  67. // Scan .claude/skills/ directories (project-level)
  68. const claudeDirs = await Array.fromAsync(
  69. Filesystem.up({
  70. targets: [".claude"],
  71. start: Instance.directory,
  72. stop: Instance.worktree,
  73. }),
  74. )
  75. // Also include global ~/.claude/skills/
  76. const globalClaude = `${Global.Path.home}/.claude`
  77. if (await Filesystem.isDir(globalClaude)) {
  78. claudeDirs.push(globalClaude)
  79. }
  80. if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
  81. for (const dir of claudeDirs) {
  82. const matches = await Array.fromAsync(
  83. CLAUDE_SKILL_GLOB.scan({
  84. cwd: dir,
  85. absolute: true,
  86. onlyFiles: true,
  87. followSymlinks: true,
  88. dot: true,
  89. }),
  90. ).catch((error) => {
  91. log.error("failed .claude directory scan for skills", { dir, error })
  92. return []
  93. })
  94. for (const match of matches) {
  95. await addSkill(match)
  96. }
  97. }
  98. }
  99. // Scan .opencode/skill/ directories
  100. for (const dir of await Config.directories()) {
  101. for await (const match of OPENCODE_SKILL_GLOB.scan({
  102. cwd: dir,
  103. absolute: true,
  104. onlyFiles: true,
  105. followSymlinks: true,
  106. })) {
  107. await addSkill(match)
  108. }
  109. }
  110. return skills
  111. })
  112. export async function get(name: string) {
  113. return state().then((x) => x[name])
  114. }
  115. export async function all() {
  116. return state().then((x) => Object.values(x))
  117. }
  118. }