skill.ts 3.8 KB

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