skill.ts 3.4 KB

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